diff --git a/Makefile b/Makefile index dc3b646..c6677b5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: test test-unit test-e2e test-go test-file test-run build clean tidy help \ check-toolchain fmt fmt-check vet lint tidy-check race-test vuln secrets \ - replace-check security check release-check release tools + replace-check security check release-check release tools \ + surface-snapshot surface-check sync-skill BINARY := $(CURDIR)/bin/fizzy VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) @@ -171,6 +172,18 @@ tools: go install golang.org/x/vuln/cmd/govulncheck@latest @echo "For gitleaks, install via: brew install gitleaks (or see https://github.com/gitleaks/gitleaks)" +# Regenerate SURFACE.txt +surface-snapshot: + GENERATE_SURFACE=1 go test ./internal/commands/ -run TestGenerateSurfaceSnapshot -v + +# Sync embedded SKILL.md from canonical source +sync-skill: + cp skills/fizzy/SKILL.md internal/skills/SKILL.md + +# CI check: SURFACE.txt is up to date +surface-check: + go test ./internal/commands/ -run TestSurfaceSnapshot -v + # Clean build artifacts clean: rm -rf bin/ diff --git a/SURFACE.txt b/SURFACE.txt new file mode 100644 index 0000000..bfbb01c --- /dev/null +++ b/SURFACE.txt @@ -0,0 +1,1662 @@ +CMD fizzy +CMD fizzy auth +CMD fizzy auth help +CMD fizzy auth login +CMD fizzy auth logout +CMD fizzy auth status +CMD fizzy board +CMD fizzy board create +CMD fizzy board delete +CMD fizzy board help +CMD fizzy board list +CMD fizzy board show +CMD fizzy board update +CMD fizzy card +CMD fizzy card assign +CMD fizzy card attachments +CMD fizzy card attachments download +CMD fizzy card attachments help +CMD fizzy card attachments show +CMD fizzy card close +CMD fizzy card column +CMD fizzy card create +CMD fizzy card delete +CMD fizzy card golden +CMD fizzy card help +CMD fizzy card image-remove +CMD fizzy card list +CMD fizzy card move +CMD fizzy card pin +CMD fizzy card postpone +CMD fizzy card reopen +CMD fizzy card self-assign +CMD fizzy card show +CMD fizzy card tag +CMD fizzy card ungolden +CMD fizzy card unpin +CMD fizzy card untriage +CMD fizzy card unwatch +CMD fizzy card update +CMD fizzy card watch +CMD fizzy column +CMD fizzy column create +CMD fizzy column delete +CMD fizzy column help +CMD fizzy column list +CMD fizzy column show +CMD fizzy column update +CMD fizzy commands +CMD fizzy comment +CMD fizzy comment attachments +CMD fizzy comment attachments download +CMD fizzy comment attachments help +CMD fizzy comment attachments show +CMD fizzy comment create +CMD fizzy comment delete +CMD fizzy comment help +CMD fizzy comment list +CMD fizzy comment show +CMD fizzy comment update +CMD fizzy completion +CMD fizzy help +CMD fizzy identity +CMD fizzy identity help +CMD fizzy identity show +CMD fizzy migrate +CMD fizzy migrate board +CMD fizzy migrate help +CMD fizzy notification +CMD fizzy notification help +CMD fizzy notification list +CMD fizzy notification read +CMD fizzy notification read-all +CMD fizzy notification tray +CMD fizzy notification unread +CMD fizzy pin +CMD fizzy pin help +CMD fizzy pin list +CMD fizzy reaction +CMD fizzy reaction create +CMD fizzy reaction delete +CMD fizzy reaction help +CMD fizzy reaction list +CMD fizzy search +CMD fizzy setup +CMD fizzy skill +CMD fizzy step +CMD fizzy step create +CMD fizzy step delete +CMD fizzy step help +CMD fizzy step show +CMD fizzy step update +CMD fizzy tag +CMD fizzy tag help +CMD fizzy tag list +CMD fizzy upload +CMD fizzy upload file +CMD fizzy upload help +CMD fizzy user +CMD fizzy user deactivate +CMD fizzy user help +CMD fizzy user list +CMD fizzy user show +CMD fizzy user update +CMD fizzy version +FLAG fizzy --account type=string +FLAG fizzy --agent type=bool +FLAG fizzy --api-url type=string +FLAG fizzy --count type=bool +FLAG fizzy --help type=bool +FLAG fizzy --ids-only type=bool +FLAG fizzy --json type=bool +FLAG fizzy --limit type=int +FLAG fizzy --markdown type=bool +FLAG fizzy --quiet type=bool +FLAG fizzy --styled type=bool +FLAG fizzy --token type=string +FLAG fizzy --verbose type=bool +FLAG fizzy auth --account type=string +FLAG fizzy auth --agent type=bool +FLAG fizzy auth --api-url type=string +FLAG fizzy auth --count type=bool +FLAG fizzy auth --help type=bool +FLAG fizzy auth --ids-only type=bool +FLAG fizzy auth --json type=bool +FLAG fizzy auth --limit type=int +FLAG fizzy auth --markdown type=bool +FLAG fizzy auth --quiet type=bool +FLAG fizzy auth --styled type=bool +FLAG fizzy auth --token type=string +FLAG fizzy auth --verbose type=bool +FLAG fizzy auth help --account type=string +FLAG fizzy auth help --agent type=bool +FLAG fizzy auth help --api-url type=string +FLAG fizzy auth help --count type=bool +FLAG fizzy auth help --help type=bool +FLAG fizzy auth help --ids-only type=bool +FLAG fizzy auth help --json type=bool +FLAG fizzy auth help --limit type=int +FLAG fizzy auth help --markdown type=bool +FLAG fizzy auth help --quiet type=bool +FLAG fizzy auth help --styled type=bool +FLAG fizzy auth help --token type=string +FLAG fizzy auth help --verbose type=bool +FLAG fizzy auth login --account type=string +FLAG fizzy auth login --agent type=bool +FLAG fizzy auth login --api-url type=string +FLAG fizzy auth login --count type=bool +FLAG fizzy auth login --help type=bool +FLAG fizzy auth login --ids-only type=bool +FLAG fizzy auth login --json type=bool +FLAG fizzy auth login --limit type=int +FLAG fizzy auth login --markdown type=bool +FLAG fizzy auth login --quiet type=bool +FLAG fizzy auth login --styled type=bool +FLAG fizzy auth login --token type=string +FLAG fizzy auth login --verbose type=bool +FLAG fizzy auth logout --account type=string +FLAG fizzy auth logout --agent type=bool +FLAG fizzy auth logout --api-url type=string +FLAG fizzy auth logout --count type=bool +FLAG fizzy auth logout --help type=bool +FLAG fizzy auth logout --ids-only type=bool +FLAG fizzy auth logout --json type=bool +FLAG fizzy auth logout --limit type=int +FLAG fizzy auth logout --markdown type=bool +FLAG fizzy auth logout --quiet type=bool +FLAG fizzy auth logout --styled type=bool +FLAG fizzy auth logout --token type=string +FLAG fizzy auth logout --verbose type=bool +FLAG fizzy auth status --account type=string +FLAG fizzy auth status --agent type=bool +FLAG fizzy auth status --api-url type=string +FLAG fizzy auth status --count type=bool +FLAG fizzy auth status --help type=bool +FLAG fizzy auth status --ids-only type=bool +FLAG fizzy auth status --json type=bool +FLAG fizzy auth status --limit type=int +FLAG fizzy auth status --markdown type=bool +FLAG fizzy auth status --quiet type=bool +FLAG fizzy auth status --styled type=bool +FLAG fizzy auth status --token type=string +FLAG fizzy auth status --verbose type=bool +FLAG fizzy board --account type=string +FLAG fizzy board --agent type=bool +FLAG fizzy board --api-url type=string +FLAG fizzy board --count type=bool +FLAG fizzy board --help type=bool +FLAG fizzy board --ids-only type=bool +FLAG fizzy board --json type=bool +FLAG fizzy board --limit type=int +FLAG fizzy board --markdown type=bool +FLAG fizzy board --quiet type=bool +FLAG fizzy board --styled type=bool +FLAG fizzy board --token type=string +FLAG fizzy board --verbose type=bool +FLAG fizzy board create --account type=string +FLAG fizzy board create --agent type=bool +FLAG fizzy board create --all_access type=string +FLAG fizzy board create --api-url type=string +FLAG fizzy board create --auto_postpone_period type=int +FLAG fizzy board create --count type=bool +FLAG fizzy board create --help type=bool +FLAG fizzy board create --ids-only type=bool +FLAG fizzy board create --json type=bool +FLAG fizzy board create --limit type=int +FLAG fizzy board create --markdown type=bool +FLAG fizzy board create --name type=string +FLAG fizzy board create --quiet type=bool +FLAG fizzy board create --styled type=bool +FLAG fizzy board create --token type=string +FLAG fizzy board create --verbose type=bool +FLAG fizzy board delete --account type=string +FLAG fizzy board delete --agent type=bool +FLAG fizzy board delete --api-url type=string +FLAG fizzy board delete --count type=bool +FLAG fizzy board delete --help type=bool +FLAG fizzy board delete --ids-only type=bool +FLAG fizzy board delete --json type=bool +FLAG fizzy board delete --limit type=int +FLAG fizzy board delete --markdown type=bool +FLAG fizzy board delete --quiet type=bool +FLAG fizzy board delete --styled type=bool +FLAG fizzy board delete --token type=string +FLAG fizzy board delete --verbose type=bool +FLAG fizzy board help --account type=string +FLAG fizzy board help --agent type=bool +FLAG fizzy board help --api-url type=string +FLAG fizzy board help --count type=bool +FLAG fizzy board help --help type=bool +FLAG fizzy board help --ids-only type=bool +FLAG fizzy board help --json type=bool +FLAG fizzy board help --limit type=int +FLAG fizzy board help --markdown type=bool +FLAG fizzy board help --quiet type=bool +FLAG fizzy board help --styled type=bool +FLAG fizzy board help --token type=string +FLAG fizzy board help --verbose type=bool +FLAG fizzy board list --account type=string +FLAG fizzy board list --agent type=bool +FLAG fizzy board list --all type=bool +FLAG fizzy board list --api-url type=string +FLAG fizzy board list --count type=bool +FLAG fizzy board list --help type=bool +FLAG fizzy board list --ids-only type=bool +FLAG fizzy board list --json type=bool +FLAG fizzy board list --limit type=int +FLAG fizzy board list --markdown type=bool +FLAG fizzy board list --page type=int +FLAG fizzy board list --quiet type=bool +FLAG fizzy board list --styled type=bool +FLAG fizzy board list --token type=string +FLAG fizzy board list --verbose type=bool +FLAG fizzy board show --account type=string +FLAG fizzy board show --agent type=bool +FLAG fizzy board show --api-url type=string +FLAG fizzy board show --count type=bool +FLAG fizzy board show --help type=bool +FLAG fizzy board show --ids-only type=bool +FLAG fizzy board show --json type=bool +FLAG fizzy board show --limit type=int +FLAG fizzy board show --markdown type=bool +FLAG fizzy board show --quiet type=bool +FLAG fizzy board show --styled type=bool +FLAG fizzy board show --token type=string +FLAG fizzy board show --verbose type=bool +FLAG fizzy board update --account type=string +FLAG fizzy board update --agent type=bool +FLAG fizzy board update --all_access type=string +FLAG fizzy board update --api-url type=string +FLAG fizzy board update --auto_postpone_period type=int +FLAG fizzy board update --count type=bool +FLAG fizzy board update --help type=bool +FLAG fizzy board update --ids-only type=bool +FLAG fizzy board update --json type=bool +FLAG fizzy board update --limit type=int +FLAG fizzy board update --markdown type=bool +FLAG fizzy board update --name type=string +FLAG fizzy board update --quiet type=bool +FLAG fizzy board update --styled type=bool +FLAG fizzy board update --token type=string +FLAG fizzy board update --verbose type=bool +FLAG fizzy card --account type=string +FLAG fizzy card --agent type=bool +FLAG fizzy card --api-url type=string +FLAG fizzy card --count type=bool +FLAG fizzy card --help type=bool +FLAG fizzy card --ids-only type=bool +FLAG fizzy card --json type=bool +FLAG fizzy card --limit type=int +FLAG fizzy card --markdown type=bool +FLAG fizzy card --quiet type=bool +FLAG fizzy card --styled type=bool +FLAG fizzy card --token type=string +FLAG fizzy card --verbose type=bool +FLAG fizzy card assign --account type=string +FLAG fizzy card assign --agent type=bool +FLAG fizzy card assign --api-url type=string +FLAG fizzy card assign --count type=bool +FLAG fizzy card assign --help type=bool +FLAG fizzy card assign --ids-only type=bool +FLAG fizzy card assign --json type=bool +FLAG fizzy card assign --limit type=int +FLAG fizzy card assign --markdown type=bool +FLAG fizzy card assign --quiet type=bool +FLAG fizzy card assign --styled type=bool +FLAG fizzy card assign --token type=string +FLAG fizzy card assign --user type=string +FLAG fizzy card assign --verbose type=bool +FLAG fizzy card attachments --account type=string +FLAG fizzy card attachments --agent type=bool +FLAG fizzy card attachments --api-url type=string +FLAG fizzy card attachments --count type=bool +FLAG fizzy card attachments --help type=bool +FLAG fizzy card attachments --ids-only type=bool +FLAG fizzy card attachments --json type=bool +FLAG fizzy card attachments --limit type=int +FLAG fizzy card attachments --markdown type=bool +FLAG fizzy card attachments --quiet type=bool +FLAG fizzy card attachments --styled type=bool +FLAG fizzy card attachments --token type=string +FLAG fizzy card attachments --verbose type=bool +FLAG fizzy card attachments download --account type=string +FLAG fizzy card attachments download --agent type=bool +FLAG fizzy card attachments download --api-url type=string +FLAG fizzy card attachments download --count type=bool +FLAG fizzy card attachments download --help type=bool +FLAG fizzy card attachments download --ids-only type=bool +FLAG fizzy card attachments download --include-comments type=bool +FLAG fizzy card attachments download --json type=bool +FLAG fizzy card attachments download --limit type=int +FLAG fizzy card attachments download --markdown type=bool +FLAG fizzy card attachments download --output type=string +FLAG fizzy card attachments download --quiet type=bool +FLAG fizzy card attachments download --styled type=bool +FLAG fizzy card attachments download --token type=string +FLAG fizzy card attachments download --verbose type=bool +FLAG fizzy card attachments help --account type=string +FLAG fizzy card attachments help --agent type=bool +FLAG fizzy card attachments help --api-url type=string +FLAG fizzy card attachments help --count type=bool +FLAG fizzy card attachments help --help type=bool +FLAG fizzy card attachments help --ids-only type=bool +FLAG fizzy card attachments help --json type=bool +FLAG fizzy card attachments help --limit type=int +FLAG fizzy card attachments help --markdown type=bool +FLAG fizzy card attachments help --quiet type=bool +FLAG fizzy card attachments help --styled type=bool +FLAG fizzy card attachments help --token type=string +FLAG fizzy card attachments help --verbose type=bool +FLAG fizzy card attachments show --account type=string +FLAG fizzy card attachments show --agent type=bool +FLAG fizzy card attachments show --api-url type=string +FLAG fizzy card attachments show --count type=bool +FLAG fizzy card attachments show --help type=bool +FLAG fizzy card attachments show --ids-only type=bool +FLAG fizzy card attachments show --include-comments type=bool +FLAG fizzy card attachments show --json type=bool +FLAG fizzy card attachments show --limit type=int +FLAG fizzy card attachments show --markdown type=bool +FLAG fizzy card attachments show --quiet type=bool +FLAG fizzy card attachments show --styled type=bool +FLAG fizzy card attachments show --token type=string +FLAG fizzy card attachments show --verbose type=bool +FLAG fizzy card close --account type=string +FLAG fizzy card close --agent type=bool +FLAG fizzy card close --api-url type=string +FLAG fizzy card close --count type=bool +FLAG fizzy card close --help type=bool +FLAG fizzy card close --ids-only type=bool +FLAG fizzy card close --json type=bool +FLAG fizzy card close --limit type=int +FLAG fizzy card close --markdown type=bool +FLAG fizzy card close --quiet type=bool +FLAG fizzy card close --styled type=bool +FLAG fizzy card close --token type=string +FLAG fizzy card close --verbose type=bool +FLAG fizzy card column --account type=string +FLAG fizzy card column --agent type=bool +FLAG fizzy card column --api-url type=string +FLAG fizzy card column --column type=string +FLAG fizzy card column --count type=bool +FLAG fizzy card column --help type=bool +FLAG fizzy card column --ids-only type=bool +FLAG fizzy card column --json type=bool +FLAG fizzy card column --limit type=int +FLAG fizzy card column --markdown type=bool +FLAG fizzy card column --quiet type=bool +FLAG fizzy card column --styled type=bool +FLAG fizzy card column --token type=string +FLAG fizzy card column --verbose type=bool +FLAG fizzy card create --account type=string +FLAG fizzy card create --agent type=bool +FLAG fizzy card create --api-url type=string +FLAG fizzy card create --board type=string +FLAG fizzy card create --count type=bool +FLAG fizzy card create --created-at type=string +FLAG fizzy card create --description type=string +FLAG fizzy card create --description_file type=string +FLAG fizzy card create --help type=bool +FLAG fizzy card create --ids-only type=bool +FLAG fizzy card create --image type=string +FLAG fizzy card create --json type=bool +FLAG fizzy card create --limit type=int +FLAG fizzy card create --markdown type=bool +FLAG fizzy card create --quiet type=bool +FLAG fizzy card create --styled type=bool +FLAG fizzy card create --tag-ids type=string +FLAG fizzy card create --title type=string +FLAG fizzy card create --token type=string +FLAG fizzy card create --verbose type=bool +FLAG fizzy card delete --account type=string +FLAG fizzy card delete --agent type=bool +FLAG fizzy card delete --api-url type=string +FLAG fizzy card delete --count type=bool +FLAG fizzy card delete --help type=bool +FLAG fizzy card delete --ids-only type=bool +FLAG fizzy card delete --json type=bool +FLAG fizzy card delete --limit type=int +FLAG fizzy card delete --markdown type=bool +FLAG fizzy card delete --quiet type=bool +FLAG fizzy card delete --styled type=bool +FLAG fizzy card delete --token type=string +FLAG fizzy card delete --verbose type=bool +FLAG fizzy card golden --account type=string +FLAG fizzy card golden --agent type=bool +FLAG fizzy card golden --api-url type=string +FLAG fizzy card golden --count type=bool +FLAG fizzy card golden --help type=bool +FLAG fizzy card golden --ids-only type=bool +FLAG fizzy card golden --json type=bool +FLAG fizzy card golden --limit type=int +FLAG fizzy card golden --markdown type=bool +FLAG fizzy card golden --quiet type=bool +FLAG fizzy card golden --styled type=bool +FLAG fizzy card golden --token type=string +FLAG fizzy card golden --verbose type=bool +FLAG fizzy card help --account type=string +FLAG fizzy card help --agent type=bool +FLAG fizzy card help --api-url type=string +FLAG fizzy card help --count type=bool +FLAG fizzy card help --help type=bool +FLAG fizzy card help --ids-only type=bool +FLAG fizzy card help --json type=bool +FLAG fizzy card help --limit type=int +FLAG fizzy card help --markdown type=bool +FLAG fizzy card help --quiet type=bool +FLAG fizzy card help --styled type=bool +FLAG fizzy card help --token type=string +FLAG fizzy card help --verbose type=bool +FLAG fizzy card image-remove --account type=string +FLAG fizzy card image-remove --agent type=bool +FLAG fizzy card image-remove --api-url type=string +FLAG fizzy card image-remove --count type=bool +FLAG fizzy card image-remove --help type=bool +FLAG fizzy card image-remove --ids-only type=bool +FLAG fizzy card image-remove --json type=bool +FLAG fizzy card image-remove --limit type=int +FLAG fizzy card image-remove --markdown type=bool +FLAG fizzy card image-remove --quiet type=bool +FLAG fizzy card image-remove --styled type=bool +FLAG fizzy card image-remove --token type=string +FLAG fizzy card image-remove --verbose type=bool +FLAG fizzy card list --account type=string +FLAG fizzy card list --agent type=bool +FLAG fizzy card list --all type=bool +FLAG fizzy card list --api-url type=string +FLAG fizzy card list --assignee type=string +FLAG fizzy card list --board type=string +FLAG fizzy card list --closed type=string +FLAG fizzy card list --closer type=string +FLAG fizzy card list --column type=string +FLAG fizzy card list --count type=bool +FLAG fizzy card list --created type=string +FLAG fizzy card list --creator type=string +FLAG fizzy card list --help type=bool +FLAG fizzy card list --ids-only type=bool +FLAG fizzy card list --indexed-by type=string +FLAG fizzy card list --json type=bool +FLAG fizzy card list --limit type=int +FLAG fizzy card list --markdown type=bool +FLAG fizzy card list --page type=int +FLAG fizzy card list --quiet type=bool +FLAG fizzy card list --search type=string +FLAG fizzy card list --sort type=string +FLAG fizzy card list --styled type=bool +FLAG fizzy card list --tag type=string +FLAG fizzy card list --token type=string +FLAG fizzy card list --unassigned type=bool +FLAG fizzy card list --verbose type=bool +FLAG fizzy card move --account type=string +FLAG fizzy card move --agent type=bool +FLAG fizzy card move --api-url type=string +FLAG fizzy card move --count type=bool +FLAG fizzy card move --help type=bool +FLAG fizzy card move --ids-only type=bool +FLAG fizzy card move --json type=bool +FLAG fizzy card move --limit type=int +FLAG fizzy card move --markdown type=bool +FLAG fizzy card move --quiet type=bool +FLAG fizzy card move --styled type=bool +FLAG fizzy card move --to type=string +FLAG fizzy card move --token type=string +FLAG fizzy card move --verbose type=bool +FLAG fizzy card pin --account type=string +FLAG fizzy card pin --agent type=bool +FLAG fizzy card pin --api-url type=string +FLAG fizzy card pin --count type=bool +FLAG fizzy card pin --help type=bool +FLAG fizzy card pin --ids-only type=bool +FLAG fizzy card pin --json type=bool +FLAG fizzy card pin --limit type=int +FLAG fizzy card pin --markdown type=bool +FLAG fizzy card pin --quiet type=bool +FLAG fizzy card pin --styled type=bool +FLAG fizzy card pin --token type=string +FLAG fizzy card pin --verbose type=bool +FLAG fizzy card postpone --account type=string +FLAG fizzy card postpone --agent type=bool +FLAG fizzy card postpone --api-url type=string +FLAG fizzy card postpone --count type=bool +FLAG fizzy card postpone --help type=bool +FLAG fizzy card postpone --ids-only type=bool +FLAG fizzy card postpone --json type=bool +FLAG fizzy card postpone --limit type=int +FLAG fizzy card postpone --markdown type=bool +FLAG fizzy card postpone --quiet type=bool +FLAG fizzy card postpone --styled type=bool +FLAG fizzy card postpone --token type=string +FLAG fizzy card postpone --verbose type=bool +FLAG fizzy card reopen --account type=string +FLAG fizzy card reopen --agent type=bool +FLAG fizzy card reopen --api-url type=string +FLAG fizzy card reopen --count type=bool +FLAG fizzy card reopen --help type=bool +FLAG fizzy card reopen --ids-only type=bool +FLAG fizzy card reopen --json type=bool +FLAG fizzy card reopen --limit type=int +FLAG fizzy card reopen --markdown type=bool +FLAG fizzy card reopen --quiet type=bool +FLAG fizzy card reopen --styled type=bool +FLAG fizzy card reopen --token type=string +FLAG fizzy card reopen --verbose type=bool +FLAG fizzy card self-assign --account type=string +FLAG fizzy card self-assign --agent type=bool +FLAG fizzy card self-assign --api-url type=string +FLAG fizzy card self-assign --count type=bool +FLAG fizzy card self-assign --help type=bool +FLAG fizzy card self-assign --ids-only type=bool +FLAG fizzy card self-assign --json type=bool +FLAG fizzy card self-assign --limit type=int +FLAG fizzy card self-assign --markdown type=bool +FLAG fizzy card self-assign --quiet type=bool +FLAG fizzy card self-assign --styled type=bool +FLAG fizzy card self-assign --token type=string +FLAG fizzy card self-assign --verbose type=bool +FLAG fizzy card show --account type=string +FLAG fizzy card show --agent type=bool +FLAG fizzy card show --api-url type=string +FLAG fizzy card show --count type=bool +FLAG fizzy card show --help type=bool +FLAG fizzy card show --ids-only type=bool +FLAG fizzy card show --json type=bool +FLAG fizzy card show --limit type=int +FLAG fizzy card show --markdown type=bool +FLAG fizzy card show --quiet type=bool +FLAG fizzy card show --styled type=bool +FLAG fizzy card show --token type=string +FLAG fizzy card show --verbose type=bool +FLAG fizzy card tag --account type=string +FLAG fizzy card tag --agent type=bool +FLAG fizzy card tag --api-url type=string +FLAG fizzy card tag --count type=bool +FLAG fizzy card tag --help type=bool +FLAG fizzy card tag --ids-only type=bool +FLAG fizzy card tag --json type=bool +FLAG fizzy card tag --limit type=int +FLAG fizzy card tag --markdown type=bool +FLAG fizzy card tag --quiet type=bool +FLAG fizzy card tag --styled type=bool +FLAG fizzy card tag --tag type=string +FLAG fizzy card tag --token type=string +FLAG fizzy card tag --verbose type=bool +FLAG fizzy card ungolden --account type=string +FLAG fizzy card ungolden --agent type=bool +FLAG fizzy card ungolden --api-url type=string +FLAG fizzy card ungolden --count type=bool +FLAG fizzy card ungolden --help type=bool +FLAG fizzy card ungolden --ids-only type=bool +FLAG fizzy card ungolden --json type=bool +FLAG fizzy card ungolden --limit type=int +FLAG fizzy card ungolden --markdown type=bool +FLAG fizzy card ungolden --quiet type=bool +FLAG fizzy card ungolden --styled type=bool +FLAG fizzy card ungolden --token type=string +FLAG fizzy card ungolden --verbose type=bool +FLAG fizzy card unpin --account type=string +FLAG fizzy card unpin --agent type=bool +FLAG fizzy card unpin --api-url type=string +FLAG fizzy card unpin --count type=bool +FLAG fizzy card unpin --help type=bool +FLAG fizzy card unpin --ids-only type=bool +FLAG fizzy card unpin --json type=bool +FLAG fizzy card unpin --limit type=int +FLAG fizzy card unpin --markdown type=bool +FLAG fizzy card unpin --quiet type=bool +FLAG fizzy card unpin --styled type=bool +FLAG fizzy card unpin --token type=string +FLAG fizzy card unpin --verbose type=bool +FLAG fizzy card untriage --account type=string +FLAG fizzy card untriage --agent type=bool +FLAG fizzy card untriage --api-url type=string +FLAG fizzy card untriage --count type=bool +FLAG fizzy card untriage --help type=bool +FLAG fizzy card untriage --ids-only type=bool +FLAG fizzy card untriage --json type=bool +FLAG fizzy card untriage --limit type=int +FLAG fizzy card untriage --markdown type=bool +FLAG fizzy card untriage --quiet type=bool +FLAG fizzy card untriage --styled type=bool +FLAG fizzy card untriage --token type=string +FLAG fizzy card untriage --verbose type=bool +FLAG fizzy card unwatch --account type=string +FLAG fizzy card unwatch --agent type=bool +FLAG fizzy card unwatch --api-url type=string +FLAG fizzy card unwatch --count type=bool +FLAG fizzy card unwatch --help type=bool +FLAG fizzy card unwatch --ids-only type=bool +FLAG fizzy card unwatch --json type=bool +FLAG fizzy card unwatch --limit type=int +FLAG fizzy card unwatch --markdown type=bool +FLAG fizzy card unwatch --quiet type=bool +FLAG fizzy card unwatch --styled type=bool +FLAG fizzy card unwatch --token type=string +FLAG fizzy card unwatch --verbose type=bool +FLAG fizzy card update --account type=string +FLAG fizzy card update --agent type=bool +FLAG fizzy card update --api-url type=string +FLAG fizzy card update --count type=bool +FLAG fizzy card update --created-at type=string +FLAG fizzy card update --description type=string +FLAG fizzy card update --description_file type=string +FLAG fizzy card update --help type=bool +FLAG fizzy card update --ids-only type=bool +FLAG fizzy card update --image type=string +FLAG fizzy card update --json type=bool +FLAG fizzy card update --limit type=int +FLAG fizzy card update --markdown type=bool +FLAG fizzy card update --quiet type=bool +FLAG fizzy card update --styled type=bool +FLAG fizzy card update --title type=string +FLAG fizzy card update --token type=string +FLAG fizzy card update --verbose type=bool +FLAG fizzy card watch --account type=string +FLAG fizzy card watch --agent type=bool +FLAG fizzy card watch --api-url type=string +FLAG fizzy card watch --count type=bool +FLAG fizzy card watch --help type=bool +FLAG fizzy card watch --ids-only type=bool +FLAG fizzy card watch --json type=bool +FLAG fizzy card watch --limit type=int +FLAG fizzy card watch --markdown type=bool +FLAG fizzy card watch --quiet type=bool +FLAG fizzy card watch --styled type=bool +FLAG fizzy card watch --token type=string +FLAG fizzy card watch --verbose type=bool +FLAG fizzy column --account type=string +FLAG fizzy column --agent type=bool +FLAG fizzy column --api-url type=string +FLAG fizzy column --count type=bool +FLAG fizzy column --help type=bool +FLAG fizzy column --ids-only type=bool +FLAG fizzy column --json type=bool +FLAG fizzy column --limit type=int +FLAG fizzy column --markdown type=bool +FLAG fizzy column --quiet type=bool +FLAG fizzy column --styled type=bool +FLAG fizzy column --token type=string +FLAG fizzy column --verbose type=bool +FLAG fizzy column create --account type=string +FLAG fizzy column create --agent type=bool +FLAG fizzy column create --api-url type=string +FLAG fizzy column create --board type=string +FLAG fizzy column create --color type=string +FLAG fizzy column create --count type=bool +FLAG fizzy column create --help type=bool +FLAG fizzy column create --ids-only type=bool +FLAG fizzy column create --json type=bool +FLAG fizzy column create --limit type=int +FLAG fizzy column create --markdown type=bool +FLAG fizzy column create --name type=string +FLAG fizzy column create --quiet type=bool +FLAG fizzy column create --styled type=bool +FLAG fizzy column create --token type=string +FLAG fizzy column create --verbose type=bool +FLAG fizzy column delete --account type=string +FLAG fizzy column delete --agent type=bool +FLAG fizzy column delete --api-url type=string +FLAG fizzy column delete --board type=string +FLAG fizzy column delete --count type=bool +FLAG fizzy column delete --help type=bool +FLAG fizzy column delete --ids-only type=bool +FLAG fizzy column delete --json type=bool +FLAG fizzy column delete --limit type=int +FLAG fizzy column delete --markdown type=bool +FLAG fizzy column delete --quiet type=bool +FLAG fizzy column delete --styled type=bool +FLAG fizzy column delete --token type=string +FLAG fizzy column delete --verbose type=bool +FLAG fizzy column help --account type=string +FLAG fizzy column help --agent type=bool +FLAG fizzy column help --api-url type=string +FLAG fizzy column help --count type=bool +FLAG fizzy column help --help type=bool +FLAG fizzy column help --ids-only type=bool +FLAG fizzy column help --json type=bool +FLAG fizzy column help --limit type=int +FLAG fizzy column help --markdown type=bool +FLAG fizzy column help --quiet type=bool +FLAG fizzy column help --styled type=bool +FLAG fizzy column help --token type=string +FLAG fizzy column help --verbose type=bool +FLAG fizzy column list --account type=string +FLAG fizzy column list --agent type=bool +FLAG fizzy column list --api-url type=string +FLAG fizzy column list --board type=string +FLAG fizzy column list --count type=bool +FLAG fizzy column list --help type=bool +FLAG fizzy column list --ids-only type=bool +FLAG fizzy column list --json type=bool +FLAG fizzy column list --limit type=int +FLAG fizzy column list --markdown type=bool +FLAG fizzy column list --quiet type=bool +FLAG fizzy column list --styled type=bool +FLAG fizzy column list --token type=string +FLAG fizzy column list --verbose type=bool +FLAG fizzy column show --account type=string +FLAG fizzy column show --agent type=bool +FLAG fizzy column show --api-url type=string +FLAG fizzy column show --board type=string +FLAG fizzy column show --count type=bool +FLAG fizzy column show --help type=bool +FLAG fizzy column show --ids-only type=bool +FLAG fizzy column show --json type=bool +FLAG fizzy column show --limit type=int +FLAG fizzy column show --markdown type=bool +FLAG fizzy column show --quiet type=bool +FLAG fizzy column show --styled type=bool +FLAG fizzy column show --token type=string +FLAG fizzy column show --verbose type=bool +FLAG fizzy column update --account type=string +FLAG fizzy column update --agent type=bool +FLAG fizzy column update --api-url type=string +FLAG fizzy column update --board type=string +FLAG fizzy column update --color type=string +FLAG fizzy column update --count type=bool +FLAG fizzy column update --help type=bool +FLAG fizzy column update --ids-only type=bool +FLAG fizzy column update --json type=bool +FLAG fizzy column update --limit type=int +FLAG fizzy column update --markdown type=bool +FLAG fizzy column update --name type=string +FLAG fizzy column update --quiet type=bool +FLAG fizzy column update --styled type=bool +FLAG fizzy column update --token type=string +FLAG fizzy column update --verbose type=bool +FLAG fizzy commands --account type=string +FLAG fizzy commands --agent type=bool +FLAG fizzy commands --api-url type=string +FLAG fizzy commands --count type=bool +FLAG fizzy commands --help type=bool +FLAG fizzy commands --ids-only type=bool +FLAG fizzy commands --json type=bool +FLAG fizzy commands --limit type=int +FLAG fizzy commands --markdown type=bool +FLAG fizzy commands --quiet type=bool +FLAG fizzy commands --styled type=bool +FLAG fizzy commands --token type=string +FLAG fizzy commands --verbose type=bool +FLAG fizzy comment --account type=string +FLAG fizzy comment --agent type=bool +FLAG fizzy comment --api-url type=string +FLAG fizzy comment --count type=bool +FLAG fizzy comment --help type=bool +FLAG fizzy comment --ids-only type=bool +FLAG fizzy comment --json type=bool +FLAG fizzy comment --limit type=int +FLAG fizzy comment --markdown type=bool +FLAG fizzy comment --quiet type=bool +FLAG fizzy comment --styled type=bool +FLAG fizzy comment --token type=string +FLAG fizzy comment --verbose type=bool +FLAG fizzy comment attachments --account type=string +FLAG fizzy comment attachments --agent type=bool +FLAG fizzy comment attachments --api-url type=string +FLAG fizzy comment attachments --count type=bool +FLAG fizzy comment attachments --help type=bool +FLAG fizzy comment attachments --ids-only type=bool +FLAG fizzy comment attachments --json type=bool +FLAG fizzy comment attachments --limit type=int +FLAG fizzy comment attachments --markdown type=bool +FLAG fizzy comment attachments --quiet type=bool +FLAG fizzy comment attachments --styled type=bool +FLAG fizzy comment attachments --token type=string +FLAG fizzy comment attachments --verbose type=bool +FLAG fizzy comment attachments download --account type=string +FLAG fizzy comment attachments download --agent type=bool +FLAG fizzy comment attachments download --api-url type=string +FLAG fizzy comment attachments download --card type=string +FLAG fizzy comment attachments download --count type=bool +FLAG fizzy comment attachments download --help type=bool +FLAG fizzy comment attachments download --ids-only type=bool +FLAG fizzy comment attachments download --json type=bool +FLAG fizzy comment attachments download --limit type=int +FLAG fizzy comment attachments download --markdown type=bool +FLAG fizzy comment attachments download --output type=string +FLAG fizzy comment attachments download --quiet type=bool +FLAG fizzy comment attachments download --styled type=bool +FLAG fizzy comment attachments download --token type=string +FLAG fizzy comment attachments download --verbose type=bool +FLAG fizzy comment attachments help --account type=string +FLAG fizzy comment attachments help --agent type=bool +FLAG fizzy comment attachments help --api-url type=string +FLAG fizzy comment attachments help --count type=bool +FLAG fizzy comment attachments help --help type=bool +FLAG fizzy comment attachments help --ids-only type=bool +FLAG fizzy comment attachments help --json type=bool +FLAG fizzy comment attachments help --limit type=int +FLAG fizzy comment attachments help --markdown type=bool +FLAG fizzy comment attachments help --quiet type=bool +FLAG fizzy comment attachments help --styled type=bool +FLAG fizzy comment attachments help --token type=string +FLAG fizzy comment attachments help --verbose type=bool +FLAG fizzy comment attachments show --account type=string +FLAG fizzy comment attachments show --agent type=bool +FLAG fizzy comment attachments show --api-url type=string +FLAG fizzy comment attachments show --card type=string +FLAG fizzy comment attachments show --count type=bool +FLAG fizzy comment attachments show --help type=bool +FLAG fizzy comment attachments show --ids-only type=bool +FLAG fizzy comment attachments show --json type=bool +FLAG fizzy comment attachments show --limit type=int +FLAG fizzy comment attachments show --markdown type=bool +FLAG fizzy comment attachments show --quiet type=bool +FLAG fizzy comment attachments show --styled type=bool +FLAG fizzy comment attachments show --token type=string +FLAG fizzy comment attachments show --verbose type=bool +FLAG fizzy comment create --account type=string +FLAG fizzy comment create --agent type=bool +FLAG fizzy comment create --api-url type=string +FLAG fizzy comment create --body type=string +FLAG fizzy comment create --body_file type=string +FLAG fizzy comment create --card type=string +FLAG fizzy comment create --count type=bool +FLAG fizzy comment create --created-at type=string +FLAG fizzy comment create --help type=bool +FLAG fizzy comment create --ids-only type=bool +FLAG fizzy comment create --json type=bool +FLAG fizzy comment create --limit type=int +FLAG fizzy comment create --markdown type=bool +FLAG fizzy comment create --quiet type=bool +FLAG fizzy comment create --styled type=bool +FLAG fizzy comment create --token type=string +FLAG fizzy comment create --verbose type=bool +FLAG fizzy comment delete --account type=string +FLAG fizzy comment delete --agent type=bool +FLAG fizzy comment delete --api-url type=string +FLAG fizzy comment delete --card type=string +FLAG fizzy comment delete --count type=bool +FLAG fizzy comment delete --help type=bool +FLAG fizzy comment delete --ids-only type=bool +FLAG fizzy comment delete --json type=bool +FLAG fizzy comment delete --limit type=int +FLAG fizzy comment delete --markdown type=bool +FLAG fizzy comment delete --quiet type=bool +FLAG fizzy comment delete --styled type=bool +FLAG fizzy comment delete --token type=string +FLAG fizzy comment delete --verbose type=bool +FLAG fizzy comment help --account type=string +FLAG fizzy comment help --agent type=bool +FLAG fizzy comment help --api-url type=string +FLAG fizzy comment help --count type=bool +FLAG fizzy comment help --help type=bool +FLAG fizzy comment help --ids-only type=bool +FLAG fizzy comment help --json type=bool +FLAG fizzy comment help --limit type=int +FLAG fizzy comment help --markdown type=bool +FLAG fizzy comment help --quiet type=bool +FLAG fizzy comment help --styled type=bool +FLAG fizzy comment help --token type=string +FLAG fizzy comment help --verbose type=bool +FLAG fizzy comment list --account type=string +FLAG fizzy comment list --agent type=bool +FLAG fizzy comment list --all type=bool +FLAG fizzy comment list --api-url type=string +FLAG fizzy comment list --card type=string +FLAG fizzy comment list --count type=bool +FLAG fizzy comment list --help type=bool +FLAG fizzy comment list --ids-only type=bool +FLAG fizzy comment list --json type=bool +FLAG fizzy comment list --limit type=int +FLAG fizzy comment list --markdown type=bool +FLAG fizzy comment list --page type=int +FLAG fizzy comment list --quiet type=bool +FLAG fizzy comment list --styled type=bool +FLAG fizzy comment list --token type=string +FLAG fizzy comment list --verbose type=bool +FLAG fizzy comment show --account type=string +FLAG fizzy comment show --agent type=bool +FLAG fizzy comment show --api-url type=string +FLAG fizzy comment show --card type=string +FLAG fizzy comment show --count type=bool +FLAG fizzy comment show --help type=bool +FLAG fizzy comment show --ids-only type=bool +FLAG fizzy comment show --json type=bool +FLAG fizzy comment show --limit type=int +FLAG fizzy comment show --markdown type=bool +FLAG fizzy comment show --quiet type=bool +FLAG fizzy comment show --styled type=bool +FLAG fizzy comment show --token type=string +FLAG fizzy comment show --verbose type=bool +FLAG fizzy comment update --account type=string +FLAG fizzy comment update --agent type=bool +FLAG fizzy comment update --api-url type=string +FLAG fizzy comment update --body type=string +FLAG fizzy comment update --body_file type=string +FLAG fizzy comment update --card type=string +FLAG fizzy comment update --count type=bool +FLAG fizzy comment update --help type=bool +FLAG fizzy comment update --ids-only type=bool +FLAG fizzy comment update --json type=bool +FLAG fizzy comment update --limit type=int +FLAG fizzy comment update --markdown type=bool +FLAG fizzy comment update --quiet type=bool +FLAG fizzy comment update --styled type=bool +FLAG fizzy comment update --token type=string +FLAG fizzy comment update --verbose type=bool +FLAG fizzy completion --account type=string +FLAG fizzy completion --agent type=bool +FLAG fizzy completion --api-url type=string +FLAG fizzy completion --count type=bool +FLAG fizzy completion --help type=bool +FLAG fizzy completion --ids-only type=bool +FLAG fizzy completion --json type=bool +FLAG fizzy completion --limit type=int +FLAG fizzy completion --markdown type=bool +FLAG fizzy completion --quiet type=bool +FLAG fizzy completion --styled type=bool +FLAG fizzy completion --token type=string +FLAG fizzy completion --verbose type=bool +FLAG fizzy help --account type=string +FLAG fizzy help --agent type=bool +FLAG fizzy help --api-url type=string +FLAG fizzy help --count type=bool +FLAG fizzy help --help type=bool +FLAG fizzy help --ids-only type=bool +FLAG fizzy help --json type=bool +FLAG fizzy help --limit type=int +FLAG fizzy help --markdown type=bool +FLAG fizzy help --quiet type=bool +FLAG fizzy help --styled type=bool +FLAG fizzy help --token type=string +FLAG fizzy help --verbose type=bool +FLAG fizzy identity --account type=string +FLAG fizzy identity --agent type=bool +FLAG fizzy identity --api-url type=string +FLAG fizzy identity --count type=bool +FLAG fizzy identity --help type=bool +FLAG fizzy identity --ids-only type=bool +FLAG fizzy identity --json type=bool +FLAG fizzy identity --limit type=int +FLAG fizzy identity --markdown type=bool +FLAG fizzy identity --quiet type=bool +FLAG fizzy identity --styled type=bool +FLAG fizzy identity --token type=string +FLAG fizzy identity --verbose type=bool +FLAG fizzy identity help --account type=string +FLAG fizzy identity help --agent type=bool +FLAG fizzy identity help --api-url type=string +FLAG fizzy identity help --count type=bool +FLAG fizzy identity help --help type=bool +FLAG fizzy identity help --ids-only type=bool +FLAG fizzy identity help --json type=bool +FLAG fizzy identity help --limit type=int +FLAG fizzy identity help --markdown type=bool +FLAG fizzy identity help --quiet type=bool +FLAG fizzy identity help --styled type=bool +FLAG fizzy identity help --token type=string +FLAG fizzy identity help --verbose type=bool +FLAG fizzy identity show --account type=string +FLAG fizzy identity show --agent type=bool +FLAG fizzy identity show --api-url type=string +FLAG fizzy identity show --count type=bool +FLAG fizzy identity show --help type=bool +FLAG fizzy identity show --ids-only type=bool +FLAG fizzy identity show --json type=bool +FLAG fizzy identity show --limit type=int +FLAG fizzy identity show --markdown type=bool +FLAG fizzy identity show --quiet type=bool +FLAG fizzy identity show --styled type=bool +FLAG fizzy identity show --token type=string +FLAG fizzy identity show --verbose type=bool +FLAG fizzy migrate --account type=string +FLAG fizzy migrate --agent type=bool +FLAG fizzy migrate --api-url type=string +FLAG fizzy migrate --count type=bool +FLAG fizzy migrate --help type=bool +FLAG fizzy migrate --ids-only type=bool +FLAG fizzy migrate --json type=bool +FLAG fizzy migrate --limit type=int +FLAG fizzy migrate --markdown type=bool +FLAG fizzy migrate --quiet type=bool +FLAG fizzy migrate --styled type=bool +FLAG fizzy migrate --token type=string +FLAG fizzy migrate --verbose type=bool +FLAG fizzy migrate board --account type=string +FLAG fizzy migrate board --agent type=bool +FLAG fizzy migrate board --api-url type=string +FLAG fizzy migrate board --count type=bool +FLAG fizzy migrate board --dry-run type=bool +FLAG fizzy migrate board --from type=string +FLAG fizzy migrate board --help type=bool +FLAG fizzy migrate board --ids-only type=bool +FLAG fizzy migrate board --include-comments type=bool +FLAG fizzy migrate board --include-images type=bool +FLAG fizzy migrate board --include-steps type=bool +FLAG fizzy migrate board --json type=bool +FLAG fizzy migrate board --limit type=int +FLAG fizzy migrate board --markdown type=bool +FLAG fizzy migrate board --quiet type=bool +FLAG fizzy migrate board --styled type=bool +FLAG fizzy migrate board --to type=string +FLAG fizzy migrate board --token type=string +FLAG fizzy migrate board --verbose type=bool +FLAG fizzy migrate help --account type=string +FLAG fizzy migrate help --agent type=bool +FLAG fizzy migrate help --api-url type=string +FLAG fizzy migrate help --count type=bool +FLAG fizzy migrate help --help type=bool +FLAG fizzy migrate help --ids-only type=bool +FLAG fizzy migrate help --json type=bool +FLAG fizzy migrate help --limit type=int +FLAG fizzy migrate help --markdown type=bool +FLAG fizzy migrate help --quiet type=bool +FLAG fizzy migrate help --styled type=bool +FLAG fizzy migrate help --token type=string +FLAG fizzy migrate help --verbose type=bool +FLAG fizzy notification --account type=string +FLAG fizzy notification --agent type=bool +FLAG fizzy notification --api-url type=string +FLAG fizzy notification --count type=bool +FLAG fizzy notification --help type=bool +FLAG fizzy notification --ids-only type=bool +FLAG fizzy notification --json type=bool +FLAG fizzy notification --limit type=int +FLAG fizzy notification --markdown type=bool +FLAG fizzy notification --quiet type=bool +FLAG fizzy notification --styled type=bool +FLAG fizzy notification --token type=string +FLAG fizzy notification --verbose type=bool +FLAG fizzy notification help --account type=string +FLAG fizzy notification help --agent type=bool +FLAG fizzy notification help --api-url type=string +FLAG fizzy notification help --count type=bool +FLAG fizzy notification help --help type=bool +FLAG fizzy notification help --ids-only type=bool +FLAG fizzy notification help --json type=bool +FLAG fizzy notification help --limit type=int +FLAG fizzy notification help --markdown type=bool +FLAG fizzy notification help --quiet type=bool +FLAG fizzy notification help --styled type=bool +FLAG fizzy notification help --token type=string +FLAG fizzy notification help --verbose type=bool +FLAG fizzy notification list --account type=string +FLAG fizzy notification list --agent type=bool +FLAG fizzy notification list --all type=bool +FLAG fizzy notification list --api-url type=string +FLAG fizzy notification list --count type=bool +FLAG fizzy notification list --help type=bool +FLAG fizzy notification list --ids-only type=bool +FLAG fizzy notification list --json type=bool +FLAG fizzy notification list --limit type=int +FLAG fizzy notification list --markdown type=bool +FLAG fizzy notification list --page type=int +FLAG fizzy notification list --quiet type=bool +FLAG fizzy notification list --styled type=bool +FLAG fizzy notification list --token type=string +FLAG fizzy notification list --verbose type=bool +FLAG fizzy notification read --account type=string +FLAG fizzy notification read --agent type=bool +FLAG fizzy notification read --api-url type=string +FLAG fizzy notification read --count type=bool +FLAG fizzy notification read --help type=bool +FLAG fizzy notification read --ids-only type=bool +FLAG fizzy notification read --json type=bool +FLAG fizzy notification read --limit type=int +FLAG fizzy notification read --markdown type=bool +FLAG fizzy notification read --quiet type=bool +FLAG fizzy notification read --styled type=bool +FLAG fizzy notification read --token type=string +FLAG fizzy notification read --verbose type=bool +FLAG fizzy notification read-all --account type=string +FLAG fizzy notification read-all --agent type=bool +FLAG fizzy notification read-all --api-url type=string +FLAG fizzy notification read-all --count type=bool +FLAG fizzy notification read-all --help type=bool +FLAG fizzy notification read-all --ids-only type=bool +FLAG fizzy notification read-all --json type=bool +FLAG fizzy notification read-all --limit type=int +FLAG fizzy notification read-all --markdown type=bool +FLAG fizzy notification read-all --quiet type=bool +FLAG fizzy notification read-all --styled type=bool +FLAG fizzy notification read-all --token type=string +FLAG fizzy notification read-all --verbose type=bool +FLAG fizzy notification tray --account type=string +FLAG fizzy notification tray --agent type=bool +FLAG fizzy notification tray --api-url type=string +FLAG fizzy notification tray --count type=bool +FLAG fizzy notification tray --help type=bool +FLAG fizzy notification tray --ids-only type=bool +FLAG fizzy notification tray --include-read type=bool +FLAG fizzy notification tray --json type=bool +FLAG fizzy notification tray --limit type=int +FLAG fizzy notification tray --markdown type=bool +FLAG fizzy notification tray --quiet type=bool +FLAG fizzy notification tray --styled type=bool +FLAG fizzy notification tray --token type=string +FLAG fizzy notification tray --verbose type=bool +FLAG fizzy notification unread --account type=string +FLAG fizzy notification unread --agent type=bool +FLAG fizzy notification unread --api-url type=string +FLAG fizzy notification unread --count type=bool +FLAG fizzy notification unread --help type=bool +FLAG fizzy notification unread --ids-only type=bool +FLAG fizzy notification unread --json type=bool +FLAG fizzy notification unread --limit type=int +FLAG fizzy notification unread --markdown type=bool +FLAG fizzy notification unread --quiet type=bool +FLAG fizzy notification unread --styled type=bool +FLAG fizzy notification unread --token type=string +FLAG fizzy notification unread --verbose type=bool +FLAG fizzy pin --account type=string +FLAG fizzy pin --agent type=bool +FLAG fizzy pin --api-url type=string +FLAG fizzy pin --count type=bool +FLAG fizzy pin --help type=bool +FLAG fizzy pin --ids-only type=bool +FLAG fizzy pin --json type=bool +FLAG fizzy pin --limit type=int +FLAG fizzy pin --markdown type=bool +FLAG fizzy pin --quiet type=bool +FLAG fizzy pin --styled type=bool +FLAG fizzy pin --token type=string +FLAG fizzy pin --verbose type=bool +FLAG fizzy pin help --account type=string +FLAG fizzy pin help --agent type=bool +FLAG fizzy pin help --api-url type=string +FLAG fizzy pin help --count type=bool +FLAG fizzy pin help --help type=bool +FLAG fizzy pin help --ids-only type=bool +FLAG fizzy pin help --json type=bool +FLAG fizzy pin help --limit type=int +FLAG fizzy pin help --markdown type=bool +FLAG fizzy pin help --quiet type=bool +FLAG fizzy pin help --styled type=bool +FLAG fizzy pin help --token type=string +FLAG fizzy pin help --verbose type=bool +FLAG fizzy pin list --account type=string +FLAG fizzy pin list --agent type=bool +FLAG fizzy pin list --api-url type=string +FLAG fizzy pin list --count type=bool +FLAG fizzy pin list --help type=bool +FLAG fizzy pin list --ids-only type=bool +FLAG fizzy pin list --json type=bool +FLAG fizzy pin list --limit type=int +FLAG fizzy pin list --markdown type=bool +FLAG fizzy pin list --quiet type=bool +FLAG fizzy pin list --styled type=bool +FLAG fizzy pin list --token type=string +FLAG fizzy pin list --verbose type=bool +FLAG fizzy reaction --account type=string +FLAG fizzy reaction --agent type=bool +FLAG fizzy reaction --api-url type=string +FLAG fizzy reaction --count type=bool +FLAG fizzy reaction --help type=bool +FLAG fizzy reaction --ids-only type=bool +FLAG fizzy reaction --json type=bool +FLAG fizzy reaction --limit type=int +FLAG fizzy reaction --markdown type=bool +FLAG fizzy reaction --quiet type=bool +FLAG fizzy reaction --styled type=bool +FLAG fizzy reaction --token type=string +FLAG fizzy reaction --verbose type=bool +FLAG fizzy reaction create --account type=string +FLAG fizzy reaction create --agent type=bool +FLAG fizzy reaction create --api-url type=string +FLAG fizzy reaction create --card type=string +FLAG fizzy reaction create --comment type=string +FLAG fizzy reaction create --content type=string +FLAG fizzy reaction create --count type=bool +FLAG fizzy reaction create --help type=bool +FLAG fizzy reaction create --ids-only type=bool +FLAG fizzy reaction create --json type=bool +FLAG fizzy reaction create --limit type=int +FLAG fizzy reaction create --markdown type=bool +FLAG fizzy reaction create --quiet type=bool +FLAG fizzy reaction create --styled type=bool +FLAG fizzy reaction create --token type=string +FLAG fizzy reaction create --verbose type=bool +FLAG fizzy reaction delete --account type=string +FLAG fizzy reaction delete --agent type=bool +FLAG fizzy reaction delete --api-url type=string +FLAG fizzy reaction delete --card type=string +FLAG fizzy reaction delete --comment type=string +FLAG fizzy reaction delete --count type=bool +FLAG fizzy reaction delete --help type=bool +FLAG fizzy reaction delete --ids-only type=bool +FLAG fizzy reaction delete --json type=bool +FLAG fizzy reaction delete --limit type=int +FLAG fizzy reaction delete --markdown type=bool +FLAG fizzy reaction delete --quiet type=bool +FLAG fizzy reaction delete --styled type=bool +FLAG fizzy reaction delete --token type=string +FLAG fizzy reaction delete --verbose type=bool +FLAG fizzy reaction help --account type=string +FLAG fizzy reaction help --agent type=bool +FLAG fizzy reaction help --api-url type=string +FLAG fizzy reaction help --count type=bool +FLAG fizzy reaction help --help type=bool +FLAG fizzy reaction help --ids-only type=bool +FLAG fizzy reaction help --json type=bool +FLAG fizzy reaction help --limit type=int +FLAG fizzy reaction help --markdown type=bool +FLAG fizzy reaction help --quiet type=bool +FLAG fizzy reaction help --styled type=bool +FLAG fizzy reaction help --token type=string +FLAG fizzy reaction help --verbose type=bool +FLAG fizzy reaction list --account type=string +FLAG fizzy reaction list --agent type=bool +FLAG fizzy reaction list --api-url type=string +FLAG fizzy reaction list --card type=string +FLAG fizzy reaction list --comment type=string +FLAG fizzy reaction list --count type=bool +FLAG fizzy reaction list --help type=bool +FLAG fizzy reaction list --ids-only type=bool +FLAG fizzy reaction list --json type=bool +FLAG fizzy reaction list --limit type=int +FLAG fizzy reaction list --markdown type=bool +FLAG fizzy reaction list --quiet type=bool +FLAG fizzy reaction list --styled type=bool +FLAG fizzy reaction list --token type=string +FLAG fizzy reaction list --verbose type=bool +FLAG fizzy search --account type=string +FLAG fizzy search --agent type=bool +FLAG fizzy search --all type=bool +FLAG fizzy search --api-url type=string +FLAG fizzy search --assignee type=string +FLAG fizzy search --board type=string +FLAG fizzy search --count type=bool +FLAG fizzy search --help type=bool +FLAG fizzy search --ids-only type=bool +FLAG fizzy search --indexed-by type=string +FLAG fizzy search --json type=bool +FLAG fizzy search --limit type=int +FLAG fizzy search --markdown type=bool +FLAG fizzy search --page type=int +FLAG fizzy search --quiet type=bool +FLAG fizzy search --sort type=string +FLAG fizzy search --styled type=bool +FLAG fizzy search --tag type=string +FLAG fizzy search --token type=string +FLAG fizzy search --verbose type=bool +FLAG fizzy setup --account type=string +FLAG fizzy setup --agent type=bool +FLAG fizzy setup --api-url type=string +FLAG fizzy setup --count type=bool +FLAG fizzy setup --help type=bool +FLAG fizzy setup --ids-only type=bool +FLAG fizzy setup --json type=bool +FLAG fizzy setup --limit type=int +FLAG fizzy setup --markdown type=bool +FLAG fizzy setup --quiet type=bool +FLAG fizzy setup --styled type=bool +FLAG fizzy setup --token type=string +FLAG fizzy setup --verbose type=bool +FLAG fizzy skill --account type=string +FLAG fizzy skill --agent type=bool +FLAG fizzy skill --api-url type=string +FLAG fizzy skill --count type=bool +FLAG fizzy skill --help type=bool +FLAG fizzy skill --ids-only type=bool +FLAG fizzy skill --json type=bool +FLAG fizzy skill --limit type=int +FLAG fizzy skill --markdown type=bool +FLAG fizzy skill --quiet type=bool +FLAG fizzy skill --styled type=bool +FLAG fizzy skill --token type=string +FLAG fizzy skill --verbose type=bool +FLAG fizzy step --account type=string +FLAG fizzy step --agent type=bool +FLAG fizzy step --api-url type=string +FLAG fizzy step --count type=bool +FLAG fizzy step --help type=bool +FLAG fizzy step --ids-only type=bool +FLAG fizzy step --json type=bool +FLAG fizzy step --limit type=int +FLAG fizzy step --markdown type=bool +FLAG fizzy step --quiet type=bool +FLAG fizzy step --styled type=bool +FLAG fizzy step --token type=string +FLAG fizzy step --verbose type=bool +FLAG fizzy step create --account type=string +FLAG fizzy step create --agent type=bool +FLAG fizzy step create --api-url type=string +FLAG fizzy step create --card type=string +FLAG fizzy step create --completed type=bool +FLAG fizzy step create --content type=string +FLAG fizzy step create --count type=bool +FLAG fizzy step create --help type=bool +FLAG fizzy step create --ids-only type=bool +FLAG fizzy step create --json type=bool +FLAG fizzy step create --limit type=int +FLAG fizzy step create --markdown type=bool +FLAG fizzy step create --quiet type=bool +FLAG fizzy step create --styled type=bool +FLAG fizzy step create --token type=string +FLAG fizzy step create --verbose type=bool +FLAG fizzy step delete --account type=string +FLAG fizzy step delete --agent type=bool +FLAG fizzy step delete --api-url type=string +FLAG fizzy step delete --card type=string +FLAG fizzy step delete --count type=bool +FLAG fizzy step delete --help type=bool +FLAG fizzy step delete --ids-only type=bool +FLAG fizzy step delete --json type=bool +FLAG fizzy step delete --limit type=int +FLAG fizzy step delete --markdown type=bool +FLAG fizzy step delete --quiet type=bool +FLAG fizzy step delete --styled type=bool +FLAG fizzy step delete --token type=string +FLAG fizzy step delete --verbose type=bool +FLAG fizzy step help --account type=string +FLAG fizzy step help --agent type=bool +FLAG fizzy step help --api-url type=string +FLAG fizzy step help --count type=bool +FLAG fizzy step help --help type=bool +FLAG fizzy step help --ids-only type=bool +FLAG fizzy step help --json type=bool +FLAG fizzy step help --limit type=int +FLAG fizzy step help --markdown type=bool +FLAG fizzy step help --quiet type=bool +FLAG fizzy step help --styled type=bool +FLAG fizzy step help --token type=string +FLAG fizzy step help --verbose type=bool +FLAG fizzy step show --account type=string +FLAG fizzy step show --agent type=bool +FLAG fizzy step show --api-url type=string +FLAG fizzy step show --card type=string +FLAG fizzy step show --count type=bool +FLAG fizzy step show --help type=bool +FLAG fizzy step show --ids-only type=bool +FLAG fizzy step show --json type=bool +FLAG fizzy step show --limit type=int +FLAG fizzy step show --markdown type=bool +FLAG fizzy step show --quiet type=bool +FLAG fizzy step show --styled type=bool +FLAG fizzy step show --token type=string +FLAG fizzy step show --verbose type=bool +FLAG fizzy step update --account type=string +FLAG fizzy step update --agent type=bool +FLAG fizzy step update --api-url type=string +FLAG fizzy step update --card type=string +FLAG fizzy step update --completed type=bool +FLAG fizzy step update --content type=string +FLAG fizzy step update --count type=bool +FLAG fizzy step update --help type=bool +FLAG fizzy step update --ids-only type=bool +FLAG fizzy step update --json type=bool +FLAG fizzy step update --limit type=int +FLAG fizzy step update --markdown type=bool +FLAG fizzy step update --not_completed type=bool +FLAG fizzy step update --quiet type=bool +FLAG fizzy step update --styled type=bool +FLAG fizzy step update --token type=string +FLAG fizzy step update --verbose type=bool +FLAG fizzy tag --account type=string +FLAG fizzy tag --agent type=bool +FLAG fizzy tag --api-url type=string +FLAG fizzy tag --count type=bool +FLAG fizzy tag --help type=bool +FLAG fizzy tag --ids-only type=bool +FLAG fizzy tag --json type=bool +FLAG fizzy tag --limit type=int +FLAG fizzy tag --markdown type=bool +FLAG fizzy tag --quiet type=bool +FLAG fizzy tag --styled type=bool +FLAG fizzy tag --token type=string +FLAG fizzy tag --verbose type=bool +FLAG fizzy tag help --account type=string +FLAG fizzy tag help --agent type=bool +FLAG fizzy tag help --api-url type=string +FLAG fizzy tag help --count type=bool +FLAG fizzy tag help --help type=bool +FLAG fizzy tag help --ids-only type=bool +FLAG fizzy tag help --json type=bool +FLAG fizzy tag help --limit type=int +FLAG fizzy tag help --markdown type=bool +FLAG fizzy tag help --quiet type=bool +FLAG fizzy tag help --styled type=bool +FLAG fizzy tag help --token type=string +FLAG fizzy tag help --verbose type=bool +FLAG fizzy tag list --account type=string +FLAG fizzy tag list --agent type=bool +FLAG fizzy tag list --all type=bool +FLAG fizzy tag list --api-url type=string +FLAG fizzy tag list --count type=bool +FLAG fizzy tag list --help type=bool +FLAG fizzy tag list --ids-only type=bool +FLAG fizzy tag list --json type=bool +FLAG fizzy tag list --limit type=int +FLAG fizzy tag list --markdown type=bool +FLAG fizzy tag list --page type=int +FLAG fizzy tag list --quiet type=bool +FLAG fizzy tag list --styled type=bool +FLAG fizzy tag list --token type=string +FLAG fizzy tag list --verbose type=bool +FLAG fizzy upload --account type=string +FLAG fizzy upload --agent type=bool +FLAG fizzy upload --api-url type=string +FLAG fizzy upload --count type=bool +FLAG fizzy upload --help type=bool +FLAG fizzy upload --ids-only type=bool +FLAG fizzy upload --json type=bool +FLAG fizzy upload --limit type=int +FLAG fizzy upload --markdown type=bool +FLAG fizzy upload --quiet type=bool +FLAG fizzy upload --styled type=bool +FLAG fizzy upload --token type=string +FLAG fizzy upload --verbose type=bool +FLAG fizzy upload file --account type=string +FLAG fizzy upload file --agent type=bool +FLAG fizzy upload file --api-url type=string +FLAG fizzy upload file --count type=bool +FLAG fizzy upload file --help type=bool +FLAG fizzy upload file --ids-only type=bool +FLAG fizzy upload file --json type=bool +FLAG fizzy upload file --limit type=int +FLAG fizzy upload file --markdown type=bool +FLAG fizzy upload file --quiet type=bool +FLAG fizzy upload file --styled type=bool +FLAG fizzy upload file --token type=string +FLAG fizzy upload file --verbose type=bool +FLAG fizzy upload help --account type=string +FLAG fizzy upload help --agent type=bool +FLAG fizzy upload help --api-url type=string +FLAG fizzy upload help --count type=bool +FLAG fizzy upload help --help type=bool +FLAG fizzy upload help --ids-only type=bool +FLAG fizzy upload help --json type=bool +FLAG fizzy upload help --limit type=int +FLAG fizzy upload help --markdown type=bool +FLAG fizzy upload help --quiet type=bool +FLAG fizzy upload help --styled type=bool +FLAG fizzy upload help --token type=string +FLAG fizzy upload help --verbose type=bool +FLAG fizzy user --account type=string +FLAG fizzy user --agent type=bool +FLAG fizzy user --api-url type=string +FLAG fizzy user --count type=bool +FLAG fizzy user --help type=bool +FLAG fizzy user --ids-only type=bool +FLAG fizzy user --json type=bool +FLAG fizzy user --limit type=int +FLAG fizzy user --markdown type=bool +FLAG fizzy user --quiet type=bool +FLAG fizzy user --styled type=bool +FLAG fizzy user --token type=string +FLAG fizzy user --verbose type=bool +FLAG fizzy user deactivate --account type=string +FLAG fizzy user deactivate --agent type=bool +FLAG fizzy user deactivate --api-url type=string +FLAG fizzy user deactivate --count type=bool +FLAG fizzy user deactivate --help type=bool +FLAG fizzy user deactivate --ids-only type=bool +FLAG fizzy user deactivate --json type=bool +FLAG fizzy user deactivate --limit type=int +FLAG fizzy user deactivate --markdown type=bool +FLAG fizzy user deactivate --quiet type=bool +FLAG fizzy user deactivate --styled type=bool +FLAG fizzy user deactivate --token type=string +FLAG fizzy user deactivate --verbose type=bool +FLAG fizzy user help --account type=string +FLAG fizzy user help --agent type=bool +FLAG fizzy user help --api-url type=string +FLAG fizzy user help --count type=bool +FLAG fizzy user help --help type=bool +FLAG fizzy user help --ids-only type=bool +FLAG fizzy user help --json type=bool +FLAG fizzy user help --limit type=int +FLAG fizzy user help --markdown type=bool +FLAG fizzy user help --quiet type=bool +FLAG fizzy user help --styled type=bool +FLAG fizzy user help --token type=string +FLAG fizzy user help --verbose type=bool +FLAG fizzy user list --account type=string +FLAG fizzy user list --agent type=bool +FLAG fizzy user list --all type=bool +FLAG fizzy user list --api-url type=string +FLAG fizzy user list --count type=bool +FLAG fizzy user list --help type=bool +FLAG fizzy user list --ids-only type=bool +FLAG fizzy user list --json type=bool +FLAG fizzy user list --limit type=int +FLAG fizzy user list --markdown type=bool +FLAG fizzy user list --page type=int +FLAG fizzy user list --quiet type=bool +FLAG fizzy user list --styled type=bool +FLAG fizzy user list --token type=string +FLAG fizzy user list --verbose type=bool +FLAG fizzy user show --account type=string +FLAG fizzy user show --agent type=bool +FLAG fizzy user show --api-url type=string +FLAG fizzy user show --count type=bool +FLAG fizzy user show --help type=bool +FLAG fizzy user show --ids-only type=bool +FLAG fizzy user show --json type=bool +FLAG fizzy user show --limit type=int +FLAG fizzy user show --markdown type=bool +FLAG fizzy user show --quiet type=bool +FLAG fizzy user show --styled type=bool +FLAG fizzy user show --token type=string +FLAG fizzy user show --verbose type=bool +FLAG fizzy user update --account type=string +FLAG fizzy user update --agent type=bool +FLAG fizzy user update --api-url type=string +FLAG fizzy user update --avatar type=string +FLAG fizzy user update --count type=bool +FLAG fizzy user update --help type=bool +FLAG fizzy user update --ids-only type=bool +FLAG fizzy user update --json type=bool +FLAG fizzy user update --limit type=int +FLAG fizzy user update --markdown type=bool +FLAG fizzy user update --name type=string +FLAG fizzy user update --quiet type=bool +FLAG fizzy user update --styled type=bool +FLAG fizzy user update --token type=string +FLAG fizzy user update --verbose type=bool +FLAG fizzy version --account type=string +FLAG fizzy version --agent type=bool +FLAG fizzy version --api-url type=string +FLAG fizzy version --count type=bool +FLAG fizzy version --help type=bool +FLAG fizzy version --ids-only type=bool +FLAG fizzy version --json type=bool +FLAG fizzy version --limit type=int +FLAG fizzy version --markdown type=bool +FLAG fizzy version --quiet type=bool +FLAG fizzy version --styled type=bool +FLAG fizzy version --token type=string +FLAG fizzy version --verbose type=bool +SUB fizzy auth +SUB fizzy auth help +SUB fizzy auth login +SUB fizzy auth logout +SUB fizzy auth status +SUB fizzy board +SUB fizzy board create +SUB fizzy board delete +SUB fizzy board help +SUB fizzy board list +SUB fizzy board show +SUB fizzy board update +SUB fizzy card +SUB fizzy card assign +SUB fizzy card attachments +SUB fizzy card attachments download +SUB fizzy card attachments help +SUB fizzy card attachments show +SUB fizzy card close +SUB fizzy card column +SUB fizzy card create +SUB fizzy card delete +SUB fizzy card golden +SUB fizzy card help +SUB fizzy card image-remove +SUB fizzy card list +SUB fizzy card move +SUB fizzy card pin +SUB fizzy card postpone +SUB fizzy card reopen +SUB fizzy card self-assign +SUB fizzy card show +SUB fizzy card tag +SUB fizzy card ungolden +SUB fizzy card unpin +SUB fizzy card untriage +SUB fizzy card unwatch +SUB fizzy card update +SUB fizzy card watch +SUB fizzy column +SUB fizzy column create +SUB fizzy column delete +SUB fizzy column help +SUB fizzy column list +SUB fizzy column show +SUB fizzy column update +SUB fizzy commands +SUB fizzy comment +SUB fizzy comment attachments +SUB fizzy comment attachments download +SUB fizzy comment attachments help +SUB fizzy comment attachments show +SUB fizzy comment create +SUB fizzy comment delete +SUB fizzy comment help +SUB fizzy comment list +SUB fizzy comment show +SUB fizzy comment update +SUB fizzy completion +SUB fizzy help +SUB fizzy identity +SUB fizzy identity help +SUB fizzy identity show +SUB fizzy migrate +SUB fizzy migrate board +SUB fizzy migrate help +SUB fizzy notification +SUB fizzy notification help +SUB fizzy notification list +SUB fizzy notification read +SUB fizzy notification read-all +SUB fizzy notification tray +SUB fizzy notification unread +SUB fizzy pin +SUB fizzy pin help +SUB fizzy pin list +SUB fizzy reaction +SUB fizzy reaction create +SUB fizzy reaction delete +SUB fizzy reaction help +SUB fizzy reaction list +SUB fizzy search +SUB fizzy setup +SUB fizzy skill +SUB fizzy step +SUB fizzy step create +SUB fizzy step delete +SUB fizzy step help +SUB fizzy step show +SUB fizzy step update +SUB fizzy tag +SUB fizzy tag help +SUB fizzy tag list +SUB fizzy upload +SUB fizzy upload file +SUB fizzy upload help +SUB fizzy user +SUB fizzy user deactivate +SUB fizzy user help +SUB fizzy user list +SUB fizzy user show +SUB fizzy user update +SUB fizzy version diff --git a/go.mod b/go.mod index 513b420..7bda828 100644 --- a/go.mod +++ b/go.mod @@ -5,28 +5,32 @@ go 1.26 require ( github.com/basecamp/cli v0.1.1 github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/yuin/goldmark v1.7.16 gopkg.in/yaml.v3 v3.0.1 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect @@ -34,8 +38,8 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/go.sum b/go.sum index d3201f6..41c3fd4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -41,12 +43,18 @@ github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJn github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -76,12 +84,16 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= diff --git a/internal/client/client.go b/internal/client/client.go index 6ed8ebd..15309be 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -15,9 +15,11 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "time" + "github.com/basecamp/cli/output" "github.com/basecamp/fizzy-cli/internal/errors" ) @@ -28,6 +30,9 @@ type Client struct { Account string HTTPClient *http.Client Verbose bool + // Sleeper is called for retry delays. Defaults to time.Sleep. + // Override in tests with a no-op or recording function. + Sleeper func(time.Duration) } // APIResponse represents a response from the API. @@ -125,7 +130,7 @@ func (c *Client) PatchMultipart(path, fileField, filePath string, fields map[str c.setHeaders(req) req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := c.HTTPClient.Do(req) + resp, err := c.doWithRetry(req) if err != nil { return nil, errors.NewNetworkError(fmt.Sprintf("Request failed: %v", err)) } @@ -149,7 +154,7 @@ func (c *Client) PatchMultipart(path, fileField, filePath string, fields map[str } if resp.StatusCode >= 400 { - return apiResp, c.errorFromResponse(resp.StatusCode, respBody) + return apiResp, c.errorFromResponse(resp.StatusCode, respBody, resp.Header) } return apiResp, nil @@ -191,7 +196,7 @@ func (c *Client) request(method, path string, body any) (*APIResponse, error) { fmt.Fprintf(os.Stderr, "> %s %s\n", method, requestURL) } - resp, err := c.HTTPClient.Do(req) + resp, err := c.doWithRetry(req) if err != nil { return nil, errors.NewNetworkError(fmt.Sprintf("Request failed: %v", err)) } @@ -216,7 +221,7 @@ func (c *Client) request(method, path string, body any) (*APIResponse, error) { // Check for error status codes before parsing JSON, // since error responses may not be JSON (e.g. HTML 401 pages) if resp.StatusCode >= 400 { - return apiResp, c.errorFromResponse(resp.StatusCode, respBody) + return apiResp, c.errorFromResponse(resp.StatusCode, respBody, resp.Header) } // Parse JSON body if present @@ -235,16 +240,116 @@ func (c *Client) setHeaders(req *http.Request) { req.Header.Set("User-Agent", "fizzy-cli/1.0") } -func (c *Client) errorFromResponse(status int, body []byte) error { +func (c *Client) sleep(d time.Duration) { + if c.Sleeper != nil { + c.Sleeper(d) + } else { + time.Sleep(d) + } +} + +const maxRetries = 3 + +// doWithRetry wraps HTTPClient.Do with retry logic for 429 and 5xx responses. +// Only retries GET/DELETE/PUT on 5xx; POST/PATCH only retry on 429. +func (c *Client) doWithRetry(req *http.Request) (*http.Response, error) { + idempotent := req.Method == "GET" || req.Method == "DELETE" || req.Method == "PUT" + + for attempt := range maxRetries + 1 { + resp, err := c.HTTPClient.Do(req) + + // Network error — retry idempotent methods with backoff + if err != nil { + if attempt < maxRetries && idempotent { + c.sleep(time.Duration(1<= 500 && idempotent && attempt < maxRetries { + _ = resp.Body.Close() + c.sleep(time.Duration(1< 0 { + return time.Duration(seconds) * time.Second + } + // Try HTTP-date format + if t, err := http.ParseTime(value); err == nil { + delay := time.Until(t) + if delay > 0 { + return delay + } + } + return time.Second +} + +func (c *Client) errorFromResponse(status int, body []byte, header http.Header) error { // Try to parse error message from response var errResp struct { Error string `json:"error"` } + + message := http.StatusText(status) if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" { - return errors.FromHTTPStatus(status, errResp.Error) + message = errResp.Error + } + + if status == 429 { + retryAfter := 0 + if ra := header.Get("Retry-After"); ra != "" { + if seconds, err := strconv.Atoi(ra); err == nil { + retryAfter = seconds + } + } + e := errors.FromHTTPStatus(429, message) + if retryAfter > 0 { + e = output.ErrRateLimit(retryAfter) + if message != "" && message != http.StatusText(http.StatusTooManyRequests) { + e.Message = message + } + } + return e } - return errors.FromHTTPStatus(status, http.StatusText(status)) + return errors.FromHTTPStatus(status, message) } // parseLinkNext extracts the "next" URL from a Link header. @@ -385,7 +490,7 @@ func (c *Client) UploadFile(filePath string) (*APIResponse, error) { } } - uploadResp, err := c.HTTPClient.Do(uploadReq) + uploadResp, err := c.doWithRetry(uploadReq) if err != nil { return nil, errors.NewNetworkError(fmt.Sprintf("Upload failed: %v", err)) } @@ -451,7 +556,7 @@ func (c *Client) UploadFileMultipart(path, fieldName, filePath string, extraFiel c.setHeaders(req) req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := c.HTTPClient.Do(req) + resp, err := c.doWithRetry(req) if err != nil { return nil, errors.NewNetworkError(fmt.Sprintf("Request failed: %v", err)) } @@ -475,7 +580,7 @@ func (c *Client) UploadFileMultipart(path, fieldName, filePath string, extraFiel } if resp.StatusCode >= 400 { - return apiResp, c.errorFromResponse(resp.StatusCode, respBody) + return apiResp, c.errorFromResponse(resp.StatusCode, respBody, resp.Header) } return apiResp, nil @@ -539,7 +644,7 @@ func (c *Client) DownloadFile(urlPath string, destPath string) error { fmt.Fprintf(os.Stderr, "> GET %s\n", requestURL) } - resp, err := c.HTTPClient.Do(req) + resp, err := c.doWithRetry(req) if err != nil { return errors.NewNetworkError(fmt.Sprintf("Request failed: %v", err)) } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 5c0f462..fb9643e 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/basecamp/fizzy-cli/internal/errors" ) @@ -280,6 +281,7 @@ func TestErrorResponses(t *testing.T) { defer server.Close() c := New(server.URL, "test-token", "") + c.Sleeper = func(d time.Duration) {} // no-op for fast tests _, err := c.Get("/resource.json") if err == nil { @@ -602,6 +604,7 @@ func TestUploadFile_FileNotFound(t *testing.T) { func TestNetworkError(t *testing.T) { c := New("http://localhost:1", "token", "") // Invalid port + c.Sleeper = func(d time.Duration) {} // no-op for fast tests _, err := c.Get("/resource.json") if err == nil { @@ -651,6 +654,147 @@ func TestUserAgentHeader(t *testing.T) { c.Get("/resource.json") } +func TestRetryOn429(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts <= 2 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(429) + w.Write([]byte(`{"error":"rate limited"}`)) + return + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]string{"id": "1"}) + })) + defer server.Close() + + var sleepCalls []time.Duration + c := New(server.URL, "test-token", "") + c.Sleeper = func(d time.Duration) { sleepCalls = append(sleepCalls, d) } + + resp, err := c.Get("/resource.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if attempts != 3 { + t.Errorf("expected 3 attempts, got %d", attempts) + } + if len(sleepCalls) != 2 { + t.Errorf("expected 2 sleep calls, got %d", len(sleepCalls)) + } + if sleepCalls[0] != time.Second { + t.Errorf("expected 1s retry delay from Retry-After header, got %v", sleepCalls[0]) + } +} + +func TestRetryOn5xx(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts <= 1 { + w.WriteHeader(503) + w.Write([]byte(`{"error":"unavailable"}`)) + return + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]string{"id": "1"}) + })) + defer server.Close() + + var sleepCalls []time.Duration + c := New(server.URL, "test-token", "") + c.Sleeper = func(d time.Duration) { sleepCalls = append(sleepCalls, d) } + + resp, err := c.Get("/resource.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if attempts != 2 { + t.Errorf("expected 2 attempts, got %d", attempts) + } + if len(sleepCalls) != 1 { + t.Errorf("expected 1 sleep call, got %d", len(sleepCalls)) + } +} + +func TestNoRetryPostOn5xx(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(500) + w.Write([]byte(`{"error":"server error"}`)) + })) + defer server.Close() + + c := New(server.URL, "test-token", "") + c.Sleeper = func(d time.Duration) {} + + _, err := c.Post("/resource.json", map[string]string{"name": "test"}) + if err == nil { + t.Fatal("expected error") + } + if attempts != 1 { + t.Errorf("expected 1 attempt (no retry for POST on 5xx), got %d", attempts) + } +} + +func TestRetryPostOn429(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts <= 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(429) + w.Write([]byte(`{"error":"rate limited"}`)) + return + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]string{"id": "1"}) + })) + defer server.Close() + + c := New(server.URL, "test-token", "") + c.Sleeper = func(d time.Duration) {} + + resp, err := c.Post("/resource.json", map[string]string{"name": "test"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if attempts != 2 { + t.Errorf("expected 2 attempts (retry POST on 429), got %d", attempts) + } +} + +func TestParseRetryAfter(t *testing.T) { + tests := []struct { + name string + value string + expected time.Duration + }{ + {"empty", "", time.Second}, + {"seconds", "5", 5 * time.Second}, + {"invalid", "not-a-number", time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := parseRetryAfter(tt.value) + if d != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, d) + } + }) + } +} + func TestDownloadFile(t *testing.T) { t.Run("successful download", func(t *testing.T) { fileContent := []byte("test file content for download") @@ -755,6 +899,7 @@ func TestDownloadFile(t *testing.T) { destPath := filepath.Join(tempDir, "network-error.txt") c := New("http://localhost:1", "test-token", "") // Invalid port + c.Sleeper = func(d time.Duration) {} // no-op for fast tests err := c.DownloadFile("/file.txt", destPath) if err == nil { diff --git a/internal/commands/attachment.go b/internal/commands/attachment.go index 17e9e5f..cee7a8a 100644 --- a/internal/commands/attachment.go +++ b/internal/commands/attachment.go @@ -72,7 +72,7 @@ Use --include-comments to also include attachments from comments on the card.`, } } - printSuccess(attachments) + printList(attachments, attachmentColumns, "", nil) return nil }, } @@ -164,10 +164,10 @@ Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and t }) } - printSuccess(map[string]any{ + printMutation(map[string]any{ "downloaded": len(results), "files": results, - }) + }, "", nil) return nil }, } diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 7492cd2..4c6d880 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -1,6 +1,9 @@ package commands import ( + "fmt" + "os" + "github.com/basecamp/cli/output" "github.com/basecamp/fizzy-cli/internal/config" "github.com/spf13/cobra" @@ -14,18 +17,30 @@ var authCmd = &cobra.Command{ var authLoginCmd = &cobra.Command{ Use: "login TOKEN", - Short: "Save API token to config file", - Long: "Saves the provided API token to ~/.config/fizzy/config.yaml for future use.", + Short: "Save API token", + Long: "Saves the provided API token to the system keyring (or fallback file).", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { token := args[0] - // Load existing config or create new - globalCfg := config.LoadGlobal() - globalCfg.Token = token + if creds != nil { + if err := credsSaveToken(token); err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } - if err := globalCfg.Save(); err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} + // Clear token from global YAML config if present (migration) + globalCfg := config.LoadGlobal() + if globalCfg.Token != "" { + globalCfg.Token = "" + _ = globalCfg.Save() + } + } else { + // Fallback: save to config file (test mode or credstore unavailable) + globalCfg := config.LoadGlobal() + globalCfg.Token = token + if err := globalCfg.Save(); err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } } // Build breadcrumbs @@ -35,10 +50,19 @@ var authLoginCmd = &cobra.Command{ breadcrumb("boards", "fizzy board list", "List boards"), } - printSuccessWithBreadcrumbs(map[string]any{ + result := map[string]any{ "authenticated": true, - "message": "Token saved to config file", - }, "", breadcrumbs) + "message": "Token saved", + } + if creds != nil { + if creds.UsingKeyring() { + result["storage"] = "keyring" + } else { + result["storage"] = "file" + } + } + + printMutation(result, "", breadcrumbs) return nil }, } @@ -46,8 +70,16 @@ var authLoginCmd = &cobra.Command{ var authLogoutCmd = &cobra.Command{ Use: "logout", Short: "Remove saved credentials", - Long: "Removes the config file containing saved credentials.", + Long: "Removes saved credentials from keyring and config file.", RunE: func(cmd *cobra.Command, args []string) error { + // Delete from credstore + if creds != nil { + if err := creds.Delete("token"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not remove token from credential store: %v\n", err) + } + } + + // Also clear any token in config file if err := config.Delete(); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } @@ -57,7 +89,7 @@ var authLogoutCmd = &cobra.Command{ breadcrumb("login", "fizzy auth login ", "Log in again"), } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "authenticated": false, "message": "Logged out successfully", }, "", breadcrumbs) @@ -89,13 +121,20 @@ var authStatusCmd = &cobra.Command{ } } + if creds != nil { + status["using_keyring"] = creds.UsingKeyring() + if w := creds.FallbackWarning(); w != "" { + status["credential_warning"] = w + } + } + // Build breadcrumbs breadcrumbs := []Breadcrumb{ breadcrumb("identity", "fizzy identity show", "View identity"), breadcrumb("logout", "fizzy auth logout", "Log out"), } - printSuccessWithBreadcrumbs(status, "", breadcrumbs) + printDetail(status, "", breadcrumbs) return nil }, } diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index 2380a83..06eba41 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -1,10 +1,12 @@ package commands import ( + "encoding/json" "os" "path/filepath" "testing" + "github.com/basecamp/cli/credstore" "github.com/basecamp/fizzy-cli/internal/config" "gopkg.in/yaml.v3" ) @@ -52,6 +54,58 @@ func TestAuthLogin(t *testing.T) { } }) + t.Run("saves token to credstore when available", func(t *testing.T) { + tempDir := t.TempDir() + configDir := t.TempDir() + + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + // Create a file-based credstore + os.Setenv("FIZZY_TEST_NO_KR", "1") + defer os.Unsetenv("FIZZY_TEST_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-test", + DisableEnvVar: "FIZZY_TEST_NO_KR", + FallbackDir: tempDir, + }) + + mock := NewMockClient() + result := SetTestMode(mock) + SetTestCreds(store) + defer ResetTestMode() + + err := authLoginCmd.RunE(authLoginCmd, []string{"cred-token-456"}) + assertExitCode(t, err, 0) + + if !result.Response.OK { + t.Error("expected success response") + } + + // Token should be in credstore (stored as JSON-encoded string) + loaded, err := store.Load("token") + if err != nil { + t.Fatalf("expected token in credstore: %v", err) + } + var tokenStr string + if err := json.Unmarshal(loaded, &tokenStr); err != nil { + t.Fatalf("expected JSON-encoded token, got %q: %v", string(loaded), err) + } + if tokenStr != "cred-token-456" { + t.Errorf("expected 'cred-token-456', got '%s'", tokenStr) + } + + // Token should NOT be in YAML config + configPath := filepath.Join(configDir, "config.yaml") + if data, err := os.ReadFile(configPath); err == nil { + var savedConfig config.Config + yaml.Unmarshal(data, &savedConfig) + if savedConfig.Token != "" { + t.Errorf("expected empty token in YAML, got '%s'", savedConfig.Token) + } + } + }) + t.Run("preserves existing config values", func(t *testing.T) { tempDir, err := os.MkdirTemp("", "fizzy-test-*") if err != nil { @@ -212,6 +266,35 @@ func TestAuthStatus(t *testing.T) { } }) + t.Run("shows using_keyring field when credstore is set", func(t *testing.T) { + mock := NewMockClient() + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + + // Create a file-based credstore (env var disables keyring probe) + tempDir := t.TempDir() + os.Setenv("FIZZY_TEST_NO_KEYRING_ALWAYS", "1") + defer os.Unsetenv("FIZZY_TEST_NO_KEYRING_ALWAYS") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-test", + DisableEnvVar: "FIZZY_TEST_NO_KEYRING_ALWAYS", + FallbackDir: tempDir, + }) + SetTestCreds(store) + defer ResetTestMode() + + err := authStatusCmd.RunE(authStatusCmd, []string{}) + assertExitCode(t, err, 0) + + data, ok := result.Response.Data.(map[string]any) + if !ok { + t.Fatal("expected map response data") + } + if _, ok := data["using_keyring"]; !ok { + t.Error("expected using_keyring field when credstore is set") + } + }) + t.Run("shows custom api_url when configured", func(t *testing.T) { tempDir, err := os.MkdirTemp("", "fizzy-test-*") if err != nil { @@ -242,3 +325,181 @@ func TestAuthStatus(t *testing.T) { } }) } + +func TestTokenMigrationToCredstore(t *testing.T) { + t.Run("migrates YAML token to credstore on first load", func(t *testing.T) { + configDir := t.TempDir() + credDir := t.TempDir() + + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + // Write a global config with a token (pre-credstore state) + os.WriteFile(filepath.Join(configDir, "config.yaml"), + []byte("token: migrate-me\naccount: acme"), 0600) + + // Set up credstore (file-based, no keyring) + os.Setenv("FIZZY_MIGRATE_NO_KR", "1") + defer os.Unsetenv("FIZZY_MIGRATE_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-migrate-test", + DisableEnvVar: "FIZZY_MIGRATE_NO_KR", + FallbackDir: credDir, + }) + + mock := NewMockClient() + SetTestMode(mock) + SetTestCreds(store) + SetTestConfig("migrate-me", "acme", "https://app.fizzy.do") + defer ResetTestMode() + + // resolveToken triggers the migration + resolveToken() + + // Token should now be in credstore + var tokenStr string + loaded, err := store.Load("token") + if err != nil { + t.Fatalf("expected token in credstore after migration: %v", err) + } + if err := json.Unmarshal(loaded, &tokenStr); err != nil { + t.Fatalf("expected JSON-encoded token, got %q: %v", string(loaded), err) + } + if tokenStr != "migrate-me" { + t.Errorf("expected 'migrate-me' in credstore, got '%s'", tokenStr) + } + + // Global YAML config should have token cleared + data, err := os.ReadFile(filepath.Join(configDir, "config.yaml")) + if err != nil { + t.Fatalf("config file missing: %v", err) + } + var savedConfig config.Config + yaml.Unmarshal(data, &savedConfig) + if savedConfig.Token != "" { + t.Errorf("expected empty token in YAML after migration, got '%s'", savedConfig.Token) + } + // Account should be preserved + if savedConfig.Account != "acme" { + t.Errorf("expected account 'acme' preserved, got '%s'", savedConfig.Account) + } + }) + + t.Run("does not migrate when credstore already has token", func(t *testing.T) { + configDir := t.TempDir() + credDir := t.TempDir() + + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + // Write a global config with a stale token + os.WriteFile(filepath.Join(configDir, "config.yaml"), + []byte("token: old-yaml-token\naccount: acme"), 0600) + + os.Setenv("FIZZY_MIGRATE2_NO_KR", "1") + defer os.Unsetenv("FIZZY_MIGRATE2_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-migrate2-test", + DisableEnvVar: "FIZZY_MIGRATE2_NO_KR", + FallbackDir: credDir, + }) + + // Pre-populate credstore with a different token + credToken, _ := json.Marshal("cred-token") + store.Save("token", credToken) + + mock := NewMockClient() + SetTestMode(mock) + SetTestCreds(store) + SetTestConfig("old-yaml-token", "acme", "https://app.fizzy.do") + defer ResetTestMode() + + resolveToken() + + // cfg.Token should be the credstore token (it wins over YAML) + if cfg.Token != "cred-token" { + t.Errorf("expected 'cred-token' from credstore, got '%s'", cfg.Token) + } + + // YAML config should be untouched (no migration needed) + data, _ := os.ReadFile(filepath.Join(configDir, "config.yaml")) + var savedConfig config.Config + yaml.Unmarshal(data, &savedConfig) + if savedConfig.Token != "old-yaml-token" { + t.Errorf("YAML token should be untouched, got '%s'", savedConfig.Token) + } + }) + + t.Run("does not migrate env-var token to credstore", func(t *testing.T) { + configDir := t.TempDir() + credDir := t.TempDir() + + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + // Global YAML config has NO token — only env var provides one + os.WriteFile(filepath.Join(configDir, "config.yaml"), + []byte("account: acme"), 0600) + + os.Setenv("FIZZY_MIGRATE3_NO_KR", "1") + defer os.Unsetenv("FIZZY_MIGRATE3_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-migrate3-test", + DisableEnvVar: "FIZZY_MIGRATE3_NO_KR", + FallbackDir: credDir, + }) + + mock := NewMockClient() + SetTestMode(mock) + SetTestCreds(store) + // cfg.Token set via env-like source (not from global YAML) + SetTestConfig("env-token", "acme", "https://app.fizzy.do") + defer ResetTestMode() + + resolveToken() + + // Credstore should remain empty — env tokens must not be persisted + if _, err := store.Load("token"); err == nil { + t.Error("env-var token should not be migrated to credstore") + } + }) + + t.Run("loads legacy raw-string token from credstore", func(t *testing.T) { + configDir := t.TempDir() + credDir := t.TempDir() + + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + os.Setenv("FIZZY_MIGRATE4_NO_KR", "1") + defer os.Unsetenv("FIZZY_MIGRATE4_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-migrate4-test", + DisableEnvVar: "FIZZY_MIGRATE4_NO_KR", + FallbackDir: credDir, + }) + + // Seed the credentials file with a value that is valid JSON (so the + // file backend can parse it) but NOT a JSON string — a number. + // json.Unmarshal into a string will fail, exercising the raw + // fallback path in credsLoadToken. This simulates what the keyring + // backend would return for a pre-JSON-encoding token. + credFile := filepath.Join(credDir, "credentials.json") + os.MkdirAll(credDir, 0700) + os.WriteFile(credFile, []byte(`{"token": 12345}`), 0600) + + mock := NewMockClient() + SetTestMode(mock) + SetTestCreds(store) + SetTestConfig("", "acme", "https://app.fizzy.do") + defer ResetTestMode() + + resolveToken() + + // The number 12345 can't json.Unmarshal into a string, so the + // fallback path returns the raw bytes as a string. + if cfg.Token != "12345" { + t.Errorf("expected legacy raw token '12345', got '%s'", cfg.Token) + } + }) +} diff --git a/internal/commands/board.go b/internal/commands/board.go index c9ec68e..e34dcaa 100644 --- a/internal/commands/board.go +++ b/internal/commands/board.go @@ -25,6 +25,9 @@ var boardListCmd = &cobra.Command{ if err := requireAuthAndAccount(); err != nil { return err } + if err := checkLimitAll(boardListAll); err != nil { + return err + } client := getClient() path := "/boards.json" @@ -65,7 +68,7 @@ var boardListCmd = &cobra.Command{ breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy board list --page %d", nextPage), "Next page")) } - printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) + printListPaginated(resp.Data, boardColumns, hasNext, resp.LinkNext, boardListAll, summary, breadcrumbs) return nil }, } @@ -103,7 +106,7 @@ var boardShowCmd = &cobra.Command{ breadcrumb("create-card", fmt.Sprintf("fizzy card create --board %s --title \"title\"", boardID), "Create card"), } - printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) + printDetail(resp.Data, summary, breadcrumbs) return nil }, } @@ -169,7 +172,7 @@ var boardCreateCmd = &cobra.Command{ } } - printSuccessWithLocationAndBreadcrumbs(followResp.Data, resp.Location, breadcrumbs) + printMutationWithLocation(followResp.Data, resp.Location, breadcrumbs) return nil } // If follow fails, just return success with location @@ -227,7 +230,7 @@ var boardUpdateCmd = &cobra.Command{ breadcrumb("cards", fmt.Sprintf("fizzy card list --board %s", boardID), "List cards"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -254,7 +257,7 @@ var boardDeleteCmd = &cobra.Command{ breadcrumb("create", "fizzy board create --name \"name\"", "Create new board"), } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "deleted": true, }, "", breadcrumbs) return nil diff --git a/internal/commands/card.go b/internal/commands/card.go index 30020d6..a7dafc1 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -40,6 +40,9 @@ var cardListCmd = &cobra.Command{ if err := requireAuthAndAccount(); err != nil { return err } + if err := checkLimitAll(cardListAll); err != nil { + return err + } boardID := defaultBoard(cardListBoard) columnFilter := strings.TrimSpace(cardListColumn) @@ -202,7 +205,7 @@ var cardListCmd = &cobra.Command{ breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy card list --page %d", nextPage), "Next page")) } - printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) + printListPaginated(resp.Data, cardColumns, hasNext, resp.LinkNext, cardListAll, summary, breadcrumbs) return nil }, } @@ -241,7 +244,7 @@ var cardShowCmd = &cobra.Command{ breadcrumb("assign", fmt.Sprintf("fizzy card assign %s --user ", cardNumber), "Assign user"), } - printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) + printDetail(resp.Data, summary, breadcrumbs) return nil }, } @@ -330,7 +333,7 @@ var cardCreateCmd = &cobra.Command{ } } - printSuccessWithLocationAndBreadcrumbs(followResp.Data, resp.Location, breadcrumbs) + printMutationWithLocation(followResp.Data, resp.Location, breadcrumbs) return nil } printSuccessWithLocation(resp.Location) @@ -399,7 +402,7 @@ var cardUpdateCmd = &cobra.Command{ breadcrumb("comment", fmt.Sprintf("fizzy comment create --card %s --body \"text\"", cardNumber), "Add comment"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -426,7 +429,7 @@ var cardDeleteCmd = &cobra.Command{ breadcrumb("create", "fizzy card create --board --title \"title\"", "Create new card"), } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "deleted": true, }, "", breadcrumbs) return nil @@ -461,7 +464,7 @@ var cardCloseCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -495,7 +498,7 @@ var cardReopenCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -528,7 +531,7 @@ var cardPostponeCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -582,7 +585,7 @@ var cardMoveCmd = &cobra.Command{ breadcrumb("triage", fmt.Sprintf("fizzy card column %s --column ", cardNumber), "Move to column"), } - printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) + printMutation(resp.Data, summary, breadcrumbs) return nil }, } @@ -625,7 +628,7 @@ var cardColumnCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil case "not_now": resp, err := client.Post("/cards/"+cardNumber+"/not_now.json", nil) @@ -636,7 +639,7 @@ var cardColumnCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil case "closed": resp, err := client.Post("/cards/"+cardNumber+"/closure.json", nil) @@ -647,7 +650,7 @@ var cardColumnCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil } } @@ -665,7 +668,7 @@ var cardColumnCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -700,7 +703,7 @@ var cardUntriageCmd = &cobra.Command{ "untriaged": true, } } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -744,7 +747,7 @@ var cardAssignCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -775,7 +778,7 @@ var cardSelfAssignCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -819,7 +822,7 @@ var cardTagCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -852,7 +855,7 @@ var cardWatchCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -885,7 +888,7 @@ var cardUnwatchCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -918,7 +921,7 @@ var cardImageRemoveCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -952,7 +955,7 @@ var cardPinCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -986,7 +989,7 @@ var cardUnpinCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -1019,7 +1022,7 @@ var cardGoldenCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -1052,7 +1055,7 @@ var cardUngoldenCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } diff --git a/internal/commands/column.go b/internal/commands/column.go index 2c9f5ab..cd75a4b 100644 --- a/internal/commands/column.go +++ b/internal/commands/column.go @@ -56,7 +56,7 @@ var columnListCmd = &cobra.Command{ breadcrumb("cards", fmt.Sprintf("fizzy card list --board %s", boardID), "List cards"), } - printSuccessWithBreadcrumbs(cols, summary, breadcrumbs) + printList(cols, columnColumns, summary, breadcrumbs) return nil }, } @@ -81,7 +81,7 @@ var columnShowCmd = &cobra.Command{ breadcrumbs := []Breadcrumb{ breadcrumb("columns", "fizzy column list --board ", "List columns"), } - printSuccessWithBreadcrumbs(pseudoColumnObject(pseudo), "", breadcrumbs) + printDetail(pseudoColumnObject(pseudo), "", breadcrumbs) return nil } @@ -102,7 +102,7 @@ var columnShowCmd = &cobra.Command{ breadcrumb("update", fmt.Sprintf("fizzy column update %s --board %s", columnID, boardID), "Update column"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printDetail(resp.Data, "", breadcrumbs) return nil }, } @@ -167,7 +167,7 @@ var columnCreateCmd = &cobra.Command{ } } - printSuccessWithLocationAndBreadcrumbs(followResp.Data, resp.Location, breadcrumbs) + printMutationWithLocation(followResp.Data, resp.Location, breadcrumbs) return nil } printSuccessWithLocation(resp.Location) @@ -229,7 +229,7 @@ var columnUpdateCmd = &cobra.Command{ breadcrumb("show", fmt.Sprintf("fizzy column show %s --board %s", columnID, boardID), "View column"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -268,7 +268,7 @@ var columnDeleteCmd = &cobra.Command{ breadcrumb("create", fmt.Sprintf("fizzy column create --board %s --name \"name\"", boardID), "Create column"), } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "deleted": true, }, "", breadcrumbs) return nil diff --git a/internal/commands/columns.go b/internal/commands/columns.go new file mode 100644 index 0000000..39e368b --- /dev/null +++ b/internal/commands/columns.go @@ -0,0 +1,60 @@ +package commands + +import "github.com/basecamp/fizzy-cli/internal/render" + +// Column definitions for styled/markdown table rendering of each entity type. +var ( + boardColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Name", Field: "name"}, + } + + cardColumns = render.Columns{ + {Header: "#", Field: "number"}, + {Header: "Title", Field: "title"}, + } + + columnColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Name", Field: "name"}, + } + + commentColumns = render.Columns{ + {Header: "ID", Field: "id"}, + } + + tagColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Title", Field: "title"}, + } + + userColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Name", Field: "name"}, + } + + notificationColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Message", Field: "message"}, + {Header: "Read", Field: "read"}, + } + + pinColumns = render.Columns{ + {Header: "#", Field: "number"}, + {Header: "Title", Field: "title"}, + } + + reactionColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Content", Field: "content"}, + } + + searchColumns = cardColumns + + attachmentColumns = render.Columns{ + {Header: "#", Field: "index"}, + {Header: "Filename", Field: "filename"}, + {Header: "Type", Field: "content_type"}, + {Header: "Size", Field: "filesize"}, + } +) diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000..f2a7030 --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,125 @@ +package commands + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// commandInfo describes a command for structured output. +type commandInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Flags []flagInfo `json:"flags,omitempty"` + Subcommands []commandInfo `json:"subcommands,omitempty"` +} + +type flagInfo struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Type string `json:"type"` + Default string `json:"default,omitempty"` + Description string `json:"description"` +} + +// commandsCmd emits a flat catalog of all commands with their flags. +var commandsCmd = &cobra.Command{ + Use: "commands", + Short: "List all available commands", + Long: "Lists all available commands with their flags in JSON format.", + RunE: func(cmd *cobra.Command, args []string) error { + catalog := walkCommands(rootCmd, "fizzy") + printSuccess(catalog) + return nil + }, +} + +func init() { + rootCmd.AddCommand(commandsCmd) +} + +// walkCommands recursively builds a command catalog. +func walkCommands(cmd *cobra.Command, prefix string) []commandInfo { + var result []commandInfo + for _, sub := range cmd.Commands() { + if sub.Hidden || sub.Name() == "help" || sub.Name() == "completion" { + continue + } + fullName := prefix + " " + sub.Name() + info := commandInfo{ + Name: fullName, + Description: sub.Short, + Flags: collectFlags(sub), + } + children := walkCommands(sub, fullName) + if len(children) > 0 { + info.Subcommands = children + } + result = append(result, info) + } + return result +} + +// collectFlags returns non-hidden local flags for a command. +func collectFlags(cmd *cobra.Command) []flagInfo { + var flags []flagInfo + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + flags = append(flags, flagInfo{ + Name: "--" + f.Name, + Shorthand: f.Shorthand, + Type: f.Value.Type(), + Default: f.DefValue, + Description: f.Usage, + }) + }) + return flags +} + +// agentHelp outputs command help as structured JSON. +func agentHelp(cmd *cobra.Command, _ []string) { + info := commandInfo{ + Name: cmd.CommandPath(), + Description: cmd.Short, + Flags: collectFlags(cmd), + } + + // Add persistent flags from parent chain + cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + info.Flags = append(info.Flags, flagInfo{ + Name: "--" + f.Name, + Shorthand: f.Shorthand, + Type: f.Value.Type(), + Default: f.DefValue, + Description: f.Usage, + }) + }) + + children := walkCommands(cmd, cmd.CommandPath()) + if len(children) > 0 { + info.Subcommands = children + } + + data, _ := json.MarshalIndent(info, "", " ") + fmt.Fprintln(outWriter, string(data)) +} + +// installAgentHelp sets the custom help function when --agent is active. +func installAgentHelp() { + rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if cfgAgent { + agentHelp(cmd, args) + } else { + // Fall back to Cobra's default help + cmd.Root().SetHelpFunc(nil) + _ = cmd.Help() + } + }) +} diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 88dcf49..120fa69 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -27,6 +27,9 @@ var commentListCmd = &cobra.Command{ if err := requireAuthAndAccount(); err != nil { return err } + if err := checkLimitAll(commentListAll); err != nil { + return err + } if commentListCard == "" { return newRequiredFlagError("card") @@ -63,7 +66,7 @@ var commentListCmd = &cobra.Command{ } hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) + printListPaginated(resp.Data, commentColumns, hasNext, resp.LinkNext, commentListAll, summary, breadcrumbs) return nil }, } @@ -101,7 +104,7 @@ var commentShowCmd = &cobra.Command{ breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", cardNumber), "List comments"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printDetail(resp.Data, "", breadcrumbs) return nil }, } @@ -168,14 +171,14 @@ var commentCreateCmd = &cobra.Command{ if resp.Location != "" { followResp, err := client.FollowLocation(resp.Location) if err == nil && followResp != nil { - printSuccessWithLocationAndBreadcrumbs(followResp.Data, resp.Location, breadcrumbs) + printMutationWithLocation(followResp.Data, resp.Location, breadcrumbs) return nil } printSuccessWithLocation(resp.Location) return nil } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -230,7 +233,7 @@ var commentUpdateCmd = &cobra.Command{ breadcrumb("comments", fmt.Sprintf("fizzy comment list --card %s", cardNumber), "List comments"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -266,7 +269,7 @@ var commentDeleteCmd = &cobra.Command{ breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "deleted": true, }, "", breadcrumbs) return nil diff --git a/internal/commands/comment_attachment.go b/internal/commands/comment_attachment.go index bfe0d38..be114fa 100644 --- a/internal/commands/comment_attachment.go +++ b/internal/commands/comment_attachment.go @@ -58,7 +58,7 @@ var commentAttachmentsShowCmd = &cobra.Command{ breadcrumb("card-attachments", fmt.Sprintf("fizzy card attachments show %s", commentAttachmentsShowCard), "Card attachments"), } - printSuccessWithBreadcrumbs(attachments, summary, breadcrumbs) + printList(attachments, attachmentColumns, summary, breadcrumbs) return nil }, } @@ -139,10 +139,10 @@ Use 'fizzy comment attachments show --card CARD_NUMBER' to see available attachm }) } - printSuccess(map[string]any{ + printMutation(map[string]any{ "downloaded": len(results), "files": results, - }) + }, "", nil) return nil }, } diff --git a/internal/commands/format_test.go b/internal/commands/format_test.go index b494fbe..bcfc451 100644 --- a/internal/commands/format_test.go +++ b/internal/commands/format_test.go @@ -12,14 +12,14 @@ import ( func TestResolveFormat(t *testing.T) { defer ResetTestMode() - t.Run("default is JSON", func(t *testing.T) { + t.Run("default is Auto", func(t *testing.T) { ResetTestMode() f, err := resolveFormat() if err != nil { t.Fatalf("unexpected error: %v", err) } - if f != output.FormatJSON { - t.Errorf("expected FormatJSON, got %v", f) + if f != output.FormatAuto { + t.Errorf("expected FormatAuto, got %v", f) } }) @@ -277,6 +277,9 @@ func runCobraWithArgs(args ...string) (string, error) { cfgQuiet = false cfgIDsOnly = false cfgCount = false + cfgAgent = false + cfgStyled = false + cfgMarkdown = false testBuf.Reset() lastRawOutput = "" out = output.New(output.Options{Format: output.FormatJSON, Writer: &testBuf}) @@ -376,3 +379,386 @@ func TestCobraMutualExclusion(t *testing.T) { t.Errorf("unexpected error message: %v", err) } } + +func TestResolveFormatAgent(t *testing.T) { + defer ResetTestMode() + + t.Run("--agent defaults to Quiet", func(t *testing.T) { + ResetTestMode() + cfgAgent = true + f, err := resolveFormat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f != output.FormatQuiet { + t.Errorf("expected FormatQuiet, got %v", f) + } + }) + + t.Run("--agent --json resolves to JSON", func(t *testing.T) { + ResetTestMode() + cfgAgent = true + cfgJSON = true + f, err := resolveFormat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f != output.FormatJSON { + t.Errorf("expected FormatJSON, got %v", f) + } + }) + + t.Run("--agent --styled is an error", func(t *testing.T) { + ResetTestMode() + cfgAgent = true + cfgStyled = true + _, err := resolveFormat() + if err == nil { + t.Fatal("expected error for --agent --styled") + } + if !strings.Contains(err.Error(), "cannot be used together") { + t.Errorf("unexpected error message: %v", err) + } + }) + + t.Run("--agent --markdown resolves to Markdown", func(t *testing.T) { + ResetTestMode() + cfgAgent = true + cfgMarkdown = true + f, err := resolveFormat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f != output.FormatMarkdown { + t.Errorf("expected FormatMarkdown, got %v", f) + } + }) + + t.Run("--agent --ids-only resolves to IDs", func(t *testing.T) { + ResetTestMode() + cfgAgent = true + cfgIDsOnly = true + f, err := resolveFormat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f != output.FormatIDs { + t.Errorf("expected FormatIDs, got %v", f) + } + }) + + t.Run("--agent --count resolves to Count", func(t *testing.T) { + ResetTestMode() + cfgAgent = true + cfgCount = true + f, err := resolveFormat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f != output.FormatCount { + t.Errorf("expected FormatCount, got %v", f) + } + }) +} + +func TestResolveFormatStyledMarkdown(t *testing.T) { + defer ResetTestMode() + + t.Run("--styled resolves to Styled", func(t *testing.T) { + ResetTestMode() + cfgStyled = true + f, err := resolveFormat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f != output.FormatStyled { + t.Errorf("expected FormatStyled, got %v", f) + } + }) + + t.Run("--markdown resolves to Markdown", func(t *testing.T) { + ResetTestMode() + cfgMarkdown = true + f, err := resolveFormat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f != output.FormatMarkdown { + t.Errorf("expected FormatMarkdown, got %v", f) + } + }) + + t.Run("--styled --json is an error", func(t *testing.T) { + ResetTestMode() + cfgStyled = true + cfgJSON = true + _, err := resolveFormat() + if err == nil { + t.Fatal("expected error for --styled --json") + } + }) + + t.Run("--markdown --quiet is an error", func(t *testing.T) { + ResetTestMode() + cfgMarkdown = true + cfgQuiet = true + _, err := resolveFormat() + if err == nil { + t.Fatal("expected error for --markdown --quiet") + } + }) +} + +func TestFormatFlagsNewRegistered(t *testing.T) { + flags := []string{"agent", "styled", "markdown"} + for _, name := range flags { + if rootCmd.PersistentFlags().Lookup(name) == nil { + t.Errorf("expected --%s flag to be registered", name) + } + } +} + +func TestSetupBlockedInMachineMode(t *testing.T) { + defer ResetTestMode() + ResetTestMode() + cfgAgent = true + err := runSetup(setupCmd, nil) + if err == nil { + t.Fatal("expected error when running setup with --agent") + } + if !strings.Contains(err.Error(), "interactive terminal") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestSkillBlockedInMachineMode(t *testing.T) { + defer ResetTestMode() + ResetTestMode() + cfgAgent = true + err := runSkill(skillCmd, nil) + if err == nil { + t.Fatal("expected error when running skill with --agent") + } + if !strings.Contains(err.Error(), "interactive terminal") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestLimitFlag(t *testing.T) { + t.Run("--limit truncates paginated list", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1", "name": "Board 1"}, + map[string]any{"id": "2", "name": "Board 2"}, + map[string]any{"id": "3", "name": "Board 3"}, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + cfgLimit = 2 + err := boardListCmd.RunE(boardListCmd, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Response == nil { + t.Fatal("expected response") + } + // Data should be truncated to 2 items + data, ok := result.Response.Data.([]any) + if !ok { + t.Fatalf("expected []any data, got %T", result.Response.Data) + } + if len(data) != 2 { + t.Errorf("expected 2 items after --limit, got %d", len(data)) + } + }) + + t.Run("--limit truncates non-paginated list", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1", "card_number": "10"}, + map[string]any{"id": "2", "card_number": "20"}, + map[string]any{"id": "3", "card_number": "30"}, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + cfgLimit = 1 + err := pinListCmd.RunE(pinListCmd, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, ok := result.Response.Data.([]any) + if !ok { + t.Fatalf("expected []any data, got %T", result.Response.Data) + } + if len(data) != 1 { + t.Errorf("expected 1 item after --limit, got %d", len(data)) + } + }) + + t.Run("--limit with notice for non-paginated", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1"}, + map[string]any{"id": "2"}, + map[string]any{"id": "3"}, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + cfgLimit = 2 + err := pinListCmd.RunE(pinListCmd, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Response.Notice != "Showing 2 of 3 results" { + t.Errorf("expected truncation notice, got %q", result.Response.Notice) + } + }) + + t.Run("--limit with notice for paginated", func(t *testing.T) { + mock := NewMockClient() + items := make([]any, 20) + for i := range items { + items[i] = map[string]any{"id": string(rune('A' + i)), "name": "Board"} + } + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: items, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + cfgLimit = 5 + err := boardListCmd.RunE(boardListCmd, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // With cfgLimit=5, TruncationNotice(5, 20, false, 5) → "Showing 5 results (use --all for complete list)" + if result.Response.Notice == "" { + t.Error("expected truncation notice for paginated list with --limit") + } + if !strings.Contains(result.Response.Notice, "5 results") { + t.Errorf("expected notice mentioning 5 results, got %q", result.Response.Notice) + } + }) + + t.Run("no notice when limit not reached", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1"}, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + cfgLimit = 10 + err := pinListCmd.RunE(pinListCmd, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Response.Notice != "" { + t.Errorf("expected no notice when data fits within limit, got %q", result.Response.Notice) + } + }) + + t.Run("--limit and --all is an error", func(t *testing.T) { + mock := NewMockClient() + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + cfgLimit = 5 + boardListAll = true + err := boardListCmd.RunE(boardListCmd, []string{}) + boardListAll = false + if err == nil { + t.Fatal("expected error for --limit with --all") + } + if !strings.Contains(err.Error(), "cannot be used together") { + t.Errorf("unexpected error message: %v", err) + } + }) + + t.Run("no limit means no truncation", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1"}, + map[string]any{"id": "2"}, + map[string]any{"id": "3"}, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + err := boardListCmd.RunE(boardListCmd, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, ok := result.Response.Data.([]any) + if !ok { + t.Fatalf("expected []any data, got %T", result.Response.Data) + } + if len(data) != 3 { + t.Errorf("expected 3 items with no limit, got %d", len(data)) + } + }) +} + +func TestLimitFlagRegistered(t *testing.T) { + if rootCmd.PersistentFlags().Lookup("limit") == nil { + t.Error("expected --limit flag to be registered") + } +} + +func TestCheckLimitAll(t *testing.T) { + t.Run("no conflict when limit is 0", func(t *testing.T) { + cfgLimit = 0 + if err := checkLimitAll(true); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("no conflict when all is false", func(t *testing.T) { + cfgLimit = 5 + if err := checkLimitAll(false); err != nil { + t.Errorf("unexpected error: %v", err) + } + cfgLimit = 0 + }) + + t.Run("conflict when both set", func(t *testing.T) { + cfgLimit = 5 + err := checkLimitAll(true) + cfgLimit = 0 + if err == nil { + t.Fatal("expected error") + } + }) +} diff --git a/internal/commands/gen_surface_test.go b/internal/commands/gen_surface_test.go new file mode 100644 index 0000000..af5ed62 --- /dev/null +++ b/internal/commands/gen_surface_test.go @@ -0,0 +1,31 @@ +package commands + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/basecamp/cli/surface" +) + +// TestGenerateSurfaceSnapshot writes SURFACE.txt from the current command tree. +// Run with: go test ./internal/commands/ -run TestGenerateSurfaceSnapshot -generate +// +// Only runs when -generate flag is provided (see Makefile surface-snapshot target). +func TestGenerateSurfaceSnapshot(t *testing.T) { + if os.Getenv("GENERATE_SURFACE") == "" { + t.Skip("set GENERATE_SURFACE=1 to regenerate SURFACE.txt") + } + + initAllHelpFlags(rootCmd) + snapshot := surface.SnapshotString(rootCmd) + "\n" + + _, thisFile, _, _ := runtime.Caller(0) + goldenPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "SURFACE.txt") + + if err := os.WriteFile(goldenPath, []byte(snapshot), 0644); err != nil { + t.Fatalf("Failed to write SURFACE.txt: %v", err) + } + t.Logf("Wrote %s", goldenPath) +} diff --git a/internal/commands/identity.go b/internal/commands/identity.go index 28cdac9..d5b0d6a 100644 --- a/internal/commands/identity.go +++ b/internal/commands/identity.go @@ -31,7 +31,7 @@ var identityShowCmd = &cobra.Command{ breadcrumb("status", "fizzy auth status", "Auth status"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printDetail(resp.Data, "", breadcrumbs) return nil }, } diff --git a/internal/commands/migrate.go b/internal/commands/migrate.go index f2bd4cc..0a35d8f 100644 --- a/internal/commands/migrate.go +++ b/internal/commands/migrate.go @@ -123,14 +123,14 @@ func runMigrateBoard(cmd *cobra.Command, args []string) error { // Dry run: just show what would be done if migrateBoardDryRun { printDryRunSummary(boardName, sourceColumns, sourceCards) - printSuccess(map[string]any{ + printMutation(map[string]any{ "dry_run": true, "board": boardName, "columns": len(sourceColumns), "cards": len(sourceCards), "from_account": migrateBoardFrom, "to_account": migrateBoardTo, - }) + }, "", nil) return nil } @@ -198,7 +198,7 @@ func runMigrateBoard(cmd *cobra.Command, args []string) error { // Print summary printMigrationSummary(stats) - printSuccess(map[string]any{ + printMutation(map[string]any{ "migrated": true, "board_id": stats.targetBoardID, "board_name": stats.targetBoardName, @@ -209,7 +209,7 @@ func runMigrateBoard(cmd *cobra.Command, args []string) error { "steps_created": stats.stepsCreated, "images_migrated": stats.imagesMigrated, "card_mapping": stats.cardMapping, - }) + }, "", nil) return nil } diff --git a/internal/commands/notification.go b/internal/commands/notification.go index beb4549..ed2e508 100644 --- a/internal/commands/notification.go +++ b/internal/commands/notification.go @@ -25,6 +25,9 @@ var notificationListCmd = &cobra.Command{ if err := requireAuthAndAccount(); err != nil { return err } + if err := checkLimitAll(notificationListAll); err != nil { + return err + } client := getClient() path := "/notifications.json" @@ -73,7 +76,7 @@ var notificationListCmd = &cobra.Command{ breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy notification list --page %d", nextPage), "Next page")) } - printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) + printListPaginated(resp.Data, notificationColumns, hasNext, resp.LinkNext, notificationListAll, summary, breadcrumbs) return nil }, } @@ -103,7 +106,7 @@ var notificationReadCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -133,7 +136,7 @@ var notificationUnreadCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -158,7 +161,7 @@ var notificationReadAllCmd = &cobra.Command{ breadcrumb("notifications", "fizzy notification list", "List notifications"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -208,7 +211,7 @@ var notificationTrayCmd = &cobra.Command{ breadcrumb("list", "fizzy notification list", "List all notifications"), } - printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) + printList(resp.Data, notificationColumns, summary, breadcrumbs) return nil }, } diff --git a/internal/commands/pin.go b/internal/commands/pin.go index 9074fb5..fd906f3 100644 --- a/internal/commands/pin.go +++ b/internal/commands/pin.go @@ -41,7 +41,7 @@ var pinListCmd = &cobra.Command{ breadcrumb("pin", "fizzy card pin ", "Pin a card"), } - printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) + printList(resp.Data, pinColumns, summary, breadcrumbs) return nil }, } diff --git a/internal/commands/reaction.go b/internal/commands/reaction.go index 4928021..a905245 100644 --- a/internal/commands/reaction.go +++ b/internal/commands/reaction.go @@ -71,7 +71,7 @@ var reactionListCmd = &cobra.Command{ } } - printSuccessWithBreadcrumbs(resp.Data, summary, breadcrumbs) + printList(resp.Data, reactionColumns, summary, breadcrumbs) return nil }, } @@ -133,7 +133,7 @@ var reactionCreateCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -184,7 +184,7 @@ var reactionDeleteCmd = &cobra.Command{ } } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "deleted": true, }, "", breadcrumbs) return nil diff --git a/internal/commands/root.go b/internal/commands/root.go index 2bc8c9b..4239c54 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -6,12 +6,18 @@ import ( "encoding/json" stderrors "errors" "fmt" + "io" "os" + "path/filepath" + "reflect" + "github.com/basecamp/cli/credstore" "github.com/basecamp/cli/output" "github.com/basecamp/fizzy-cli/internal/client" "github.com/basecamp/fizzy-cli/internal/config" "github.com/basecamp/fizzy-cli/internal/errors" + "github.com/basecamp/fizzy-cli/internal/render" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" ) @@ -20,14 +26,18 @@ type Breadcrumb = output.Breadcrumb var ( // Global flags - cfgToken string - cfgAccount string - cfgAPIURL string - cfgVerbose bool - cfgJSON bool - cfgQuiet bool - cfgIDsOnly bool - cfgCount bool + cfgToken string + cfgAccount string + cfgAPIURL string + cfgVerbose bool + cfgJSON bool + cfgQuiet bool + cfgIDsOnly bool + cfgCount bool + cfgAgent bool + cfgStyled bool + cfgMarkdown bool + cfgLimit int // Loaded config cfg *config.Config @@ -35,8 +45,12 @@ var ( // Client factory (can be overridden for testing) clientFactory func() client.API + // Credential store + creds *credstore.Store + // Output writer - out *output.Writer + out *output.Writer + outWriter io.Writer // raw writer for styled/markdown rendering ) // rootCmd represents the base command. @@ -55,8 +69,10 @@ Use fizzy to manage boards, cards, comments, and more from your terminal.`, } if lastResult != nil { // Test mode — preserve test buffer as writer. + outWriter = &testBuf out = output.New(output.Options{Format: format, Writer: &testBuf}) } else { + outWriter = os.Stdout out = output.New(output.Options{Format: format, Writer: os.Stdout}) } @@ -66,10 +82,23 @@ Use fizzy to manage boards, cards, comments, and more from your terminal.`, cfg = config.Load() } - // Override with command-line flags - if cfgToken != "" { - cfg.Token = cfgToken + // Initialize credential store (skip in test mode) + if creds == nil && lastResult == nil { + fallbackDir := "" + if cfgPath, err := config.ConfigPath(); err == nil { + fallbackDir = filepath.Join(filepath.Dir(cfgPath), "credentials") + } else if home, err := os.UserHomeDir(); err == nil { + fallbackDir = filepath.Join(home, ".config", "fizzy", "credentials") + } + creds = credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy", + DisableEnvVar: "FIZZY_NO_KEYRING", + FallbackDir: fallbackDir, + }) } + + resolveToken() + if cfgAccount != "" { cfg.Account = cfgAccount } @@ -77,6 +106,11 @@ Use fizzy to manage boards, cards, comments, and more from your terminal.`, cfg.APIURL = cfgAPIURL } + // FIZZY_DEBUG enables verbose output + if os.Getenv("FIZZY_DEBUG") != "" { + cfgVerbose = true + } + return nil }, SilenceUsage: true, @@ -93,8 +127,9 @@ func SetVersion(v string) { // Execute runs the root command. func Execute() { - // Default to JSON — PersistentPreRunE will re-resolve from parsed flags. - out = output.New(output.Options{Format: output.FormatJSON, Writer: os.Stdout}) + // Default to Auto — PersistentPreRunE will re-resolve from parsed flags. + outWriter = os.Stdout + out = output.New(output.Options{Format: output.FormatAuto, Writer: os.Stdout}) if err := rootCmd.Execute(); err != nil { var e *output.Error if !stderrors.As(err, &e) { @@ -107,8 +142,9 @@ func Execute() { } // resolveFormat returns the output format from flags. -// Default is FormatJSON. At most one format flag may be set. +// Default is FormatAuto (TTY→Styled, pipe→JSON). At most one format flag may be set. func resolveFormat() (output.Format, error) { + // Count mutually exclusive format flags n := 0 if cfgJSON { n++ @@ -122,10 +158,22 @@ func resolveFormat() (output.Format, error) { if cfgCount { n++ } + if cfgStyled { + n++ + } + if cfgMarkdown { + n++ + } if n > 1 { - return 0, fmt.Errorf("only one output format flag may be used at a time (--json, --quiet, --ids-only, --count)") + return 0, fmt.Errorf("only one output format flag may be used at a time (--json, --quiet, --ids-only, --count, --styled, --markdown)") + } + + // --agent is orthogonal to format flags but --agent --styled is an error + if cfgAgent && cfgStyled { + return 0, fmt.Errorf("--agent and --styled cannot be used together") } + // Explicit format flag wins switch { case cfgQuiet: return output.FormatQuiet, nil @@ -133,9 +181,35 @@ func resolveFormat() (output.Format, error) { return output.FormatIDs, nil case cfgCount: return output.FormatCount, nil - default: + case cfgJSON: return output.FormatJSON, nil + case cfgStyled: + return output.FormatStyled, nil + case cfgMarkdown: + return output.FormatMarkdown, nil + } + + // --agent alone defaults to FormatQuiet + if cfgAgent { + return output.FormatQuiet, nil + } + + return output.FormatAuto, nil +} + +// IsMachineOutput returns true when output should be treated as machine-consumable. +// True when any machine format flag is set, --agent is set, or stdout/stdin is not a TTY. +func IsMachineOutput() bool { + if cfgAgent || cfgJSON || cfgQuiet || cfgIDsOnly || cfgCount { + return true + } + if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { + return true + } + if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) { + return true } + return false } func init() { @@ -143,10 +217,16 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgAccount, "account", "", "Account slug") rootCmd.PersistentFlags().StringVar(&cfgAPIURL, "api-url", "", "API base URL") rootCmd.PersistentFlags().BoolVar(&cfgVerbose, "verbose", false, "Show request/response details") - rootCmd.PersistentFlags().BoolVar(&cfgJSON, "json", false, "JSON envelope output (default)") + rootCmd.PersistentFlags().BoolVar(&cfgJSON, "json", false, "JSON envelope output") rootCmd.PersistentFlags().BoolVar(&cfgQuiet, "quiet", false, "Raw JSON data without envelope") rootCmd.PersistentFlags().BoolVar(&cfgIDsOnly, "ids-only", false, "Print one ID per line") rootCmd.PersistentFlags().BoolVar(&cfgCount, "count", false, "Print count of results") + rootCmd.PersistentFlags().BoolVar(&cfgAgent, "agent", false, "Agent mode (default: quiet format, no interactive prompts)") + rootCmd.PersistentFlags().BoolVar(&cfgStyled, "styled", false, "Styled terminal output with colors") + rootCmd.PersistentFlags().BoolVar(&cfgMarkdown, "markdown", false, "Markdown formatted output") + rootCmd.PersistentFlags().IntVar(&cfgLimit, "limit", 0, "Maximum number of results to display") + + installAgentHelp() } // getClient returns an API client configured from global settings. @@ -260,22 +340,6 @@ func printSuccessWithBreadcrumbs(data any, summary string, breadcrumbs []Breadcr captureResponse() } -// printSuccessWithPaginationAndBreadcrumbs prints a success response with pagination and breadcrumbs. -func printSuccessWithPaginationAndBreadcrumbs(data any, hasNext bool, nextURL string, summary string, breadcrumbs []Breadcrumb) { - opts := []output.ResponseOption{output.WithBreadcrumbs(breadcrumbs...)} - if summary != "" { - opts = append(opts, output.WithSummary(summary)) - } - if hasNext || nextURL != "" { - opts = append(opts, output.WithContext("pagination", map[string]any{ - "has_next": hasNext, - "next_url": nextURL, - })) - } - _ = out.OK(data, opts...) - captureResponse() -} - // printSuccessWithLocationAndBreadcrumbs prints a success response with both location and breadcrumbs. func printSuccessWithLocationAndBreadcrumbs(data any, location string, breadcrumbs []Breadcrumb) { _ = out.OK(data, @@ -285,6 +349,205 @@ func printSuccessWithLocationAndBreadcrumbs(data any, location string, breadcrum captureResponse() } +// defaultPageSize is the Fizzy API's default page size. +const defaultPageSize = 20 + +// checkLimitAll validates that --limit and --all are not both set. +func checkLimitAll(all bool) error { + if cfgLimit > 0 && all { + return errors.NewInvalidArgsError("--limit and --all cannot be used together") + } + return nil +} + +// truncateData applies --limit client-side truncation to a slice. +// Returns the (possibly truncated) data and the original count. +// Handles both []any and typed slices (e.g. []Attachment). +func truncateData(data any) (any, int) { + if arr, ok := data.([]any); ok { + originalCount := len(arr) + if cfgLimit > 0 && originalCount > cfgLimit { + return arr[:cfgLimit], originalCount + } + return data, originalCount + } + // Handle typed slices via reflect + v := reflect.ValueOf(data) + if v.Kind() == reflect.Slice { + originalCount := v.Len() + if cfgLimit > 0 && originalCount > cfgLimit { + return v.Slice(0, cfgLimit).Interface(), originalCount + } + return data, originalCount + } + return data, 0 +} + +// dataCount returns the length of data if it's a slice. +func dataCount(data any) int { + if arr, ok := data.([]any); ok { + return len(arr) + } + v := reflect.ValueOf(data) + if v.Kind() == reflect.Slice { + return v.Len() + } + return 0 +} + +// printList renders list data with format-aware dispatch. +// For non-paginated lists (no --all flag). Applies --limit truncation. +func printList(data any, cols render.Columns, summary string, breadcrumbs []Breadcrumb) { + data, originalCount := truncateData(data) + + // For non-paginated lists, generate a simple limit notice (no --all to suggest) + notice := "" + if cfgLimit > 0 && originalCount > cfgLimit { + notice = fmt.Sprintf("Showing %d of %d results", cfgLimit, originalCount) + } + + switch out.EffectiveFormat() { + case output.FormatStyled: + fmt.Fprint(outWriter, render.StyledList(toMaps(data), cols, summary)) + if notice != "" { + fmt.Fprintf(outWriter, "\n%s\n", notice) + } + captureResponse() + case output.FormatMarkdown: + fmt.Fprint(outWriter, render.MarkdownList(toMaps(data), cols, summary)) + if notice != "" { + fmt.Fprintf(outWriter, "\n%s\n", notice) + } + captureResponse() + default: + opts := []output.ResponseOption{output.WithBreadcrumbs(breadcrumbs...)} + if summary != "" { + opts = append(opts, output.WithSummary(summary)) + } + if notice != "" { + opts = append(opts, output.WithNotice(notice)) + } + _ = out.OK(data, opts...) + captureResponse() + } +} + +// printListPaginated renders paginated list data with format-aware dispatch. +// For paginated lists (commands with --all flag). Applies --limit truncation and truncation notices. +func printListPaginated(data any, cols render.Columns, hasNext bool, nextURL string, all bool, summary string, breadcrumbs []Breadcrumb) { + data, _ = truncateData(data) + notice := output.TruncationNotice(dataCount(data), defaultPageSize, all, cfgLimit) + + switch out.EffectiveFormat() { + case output.FormatStyled: + fmt.Fprint(outWriter, render.StyledList(toMaps(data), cols, summary)) + if notice != "" { + fmt.Fprintf(outWriter, "\n%s\n", notice) + } + captureResponse() + case output.FormatMarkdown: + fmt.Fprint(outWriter, render.MarkdownList(toMaps(data), cols, summary)) + if notice != "" { + fmt.Fprintf(outWriter, "\n%s\n", notice) + } + captureResponse() + default: + opts := []output.ResponseOption{output.WithBreadcrumbs(breadcrumbs...)} + if summary != "" { + opts = append(opts, output.WithSummary(summary)) + } + if notice != "" { + opts = append(opts, output.WithNotice(notice)) + } + if hasNext || nextURL != "" { + opts = append(opts, output.WithContext("pagination", map[string]any{ + "has_next": hasNext, + "next_url": nextURL, + })) + } + _ = out.OK(data, opts...) + captureResponse() + } +} + +// printDetail renders a single object with format-aware dispatch. +func printDetail(data any, summary string, breadcrumbs []Breadcrumb) { + switch out.EffectiveFormat() { + case output.FormatStyled: + fmt.Fprint(outWriter, render.StyledDetail(toMap(data), summary)) + captureResponse() + case output.FormatMarkdown: + fmt.Fprint(outWriter, render.MarkdownDetail(toMap(data), summary)) + captureResponse() + default: + printSuccessWithBreadcrumbs(data, summary, breadcrumbs) + } +} + +// printMutationWithLocation renders a mutation result that includes a location URL. +func printMutationWithLocation(data any, location string, breadcrumbs []Breadcrumb) { + switch out.EffectiveFormat() { + case output.FormatStyled: + fmt.Fprint(outWriter, render.StyledDetail(toMap(data), "")) + captureResponse() + case output.FormatMarkdown: + fmt.Fprint(outWriter, render.MarkdownDetail(toMap(data), "")) + captureResponse() + default: + printSuccessWithLocationAndBreadcrumbs(data, location, breadcrumbs) + } +} + +// printMutation renders a mutation result with format-aware dispatch. +// For styled/markdown, uses summary rendering for simple confirmations. +func printMutation(data any, summary string, breadcrumbs []Breadcrumb) { + switch out.EffectiveFormat() { + case output.FormatStyled: + fmt.Fprint(outWriter, render.StyledSummary(toMap(data), summary)) + captureResponse() + case output.FormatMarkdown: + fmt.Fprint(outWriter, render.MarkdownSummary(toMap(data), summary)) + captureResponse() + default: + printSuccessWithBreadcrumbs(data, summary, breadcrumbs) + } +} + +// toMaps converts any (expected []any of map[string]any) to []map[string]any. +// Falls back to JSON round-trip for typed slices (e.g., []Attachment). +func toMaps(data any) []map[string]any { + if data == nil { + return nil + } + if slice, ok := data.([]any); ok { + result := make([]map[string]any, 0, len(slice)) + for _, item := range slice { + if m, ok := item.(map[string]any); ok { + result = append(result, m) + } + } + return result + } + // JSON round-trip for typed structs + b, err := json.Marshal(data) + if err != nil { + return nil + } + var result []map[string]any + if json.Unmarshal(b, &result) == nil { + return result + } + return nil +} + +// toMap converts any (expected map[string]any) to map[string]any. +func toMap(data any) map[string]any { + if m, ok := data.(map[string]any); ok { + return m + } + return nil +} + // SetTestMode configures the commands package for testing. // It sets a mock client factory and captures results instead of exiting. func SetTestMode(mockClient client.API) *CommandResult { @@ -292,6 +555,7 @@ func SetTestMode(mockClient client.API) *CommandResult { return mockClient } testBuf.Reset() + outWriter = &testBuf out = output.New(output.Options{Format: output.FormatJSON, Writer: &testBuf}) lastResult = &CommandResult{} return lastResult @@ -301,6 +565,7 @@ func SetTestMode(mockClient client.API) *CommandResult { // Must be called after SetTestMode. func SetTestFormat(format output.Format) { testBuf.Reset() + outWriter = &testBuf out = output.New(output.Options{Format: format, Writer: &testBuf}) } @@ -310,6 +575,67 @@ func TestOutput() string { return lastRawOutput } +// credsSaveToken JSON-encodes a token and saves it to the credential store. +// The file backend requires valid JSON (values are stored as json.RawMessage). +func credsSaveToken(token string) error { + data, err := json.Marshal(token) + if err != nil { + return err + } + return creds.Save("token", data) +} + +// credsLoadToken loads and JSON-decodes a token from the credential store. +// Falls back to treating the payload as a raw string for backward compatibility +// with pre-JSON credstore entries. +func credsLoadToken() (string, error) { + data, err := creds.Load("token") + if err != nil { + return "", err + } + var token string + if json.Unmarshal(data, &token) == nil { + return token, nil + } + // Legacy: raw string stored before JSON encoding was adopted + return string(data), nil +} + +// resolveToken applies token precedence: YAML → credstore (with migration) → env → flag. +func resolveToken() { + // 1. YAML file (global + local, already in cfg.Token from config.Load()) + // 2. credstore (overrides YAML — credstore is the "new" storage) + if creds != nil { + if t, err := credsLoadToken(); err == nil && t != "" { + cfg.Token = t + } else { + // Auto-migrate: if the global YAML config has a token but credstore + // doesn't, migrate it. Only read from the global file directly to + // avoid persisting env-var or local-config tokens. + globalCfg := config.LoadGlobal() + if globalCfg.Token != "" { + if err := credsSaveToken(globalCfg.Token); err == nil { + globalCfg.Token = "" + _ = globalCfg.Save() + } + } + } + } + // 3. env var (overrides credstore) + if t := os.Getenv("FIZZY_TOKEN"); t != "" { + cfg.Token = t + } + // 4. CLI flag (overrides everything) + if cfgToken != "" { + cfg.Token = cfgToken + } +} + +// SetTestCreds sets the credential store for testing. +func SetTestCreds(store *credstore.Store) { + creds = store +} + // SetTestConfig sets the config for testing. func SetTestConfig(token, account, apiURL string) { cfg = &config.Config{ @@ -325,10 +651,15 @@ func ResetTestMode() { lastResult = nil lastRawOutput = "" cfg = nil + creds = nil cfgJSON = false cfgQuiet = false cfgIDsOnly = false cfgCount = false + cfgAgent = false + cfgStyled = false + cfgMarkdown = false + cfgLimit = 0 } // GetRootCmd returns the root command for testing. diff --git a/internal/commands/search.go b/internal/commands/search.go index e29cb7c..5dbd3fe 100644 --- a/internal/commands/search.go +++ b/internal/commands/search.go @@ -26,6 +26,9 @@ var searchCmd = &cobra.Command{ if err := requireAuthAndAccount(); err != nil { return err } + if err := checkLimitAll(searchAll); err != nil { + return err + } query := strings.Join(args, " ") @@ -88,7 +91,7 @@ var searchCmd = &cobra.Command{ } hasNext := resp.LinkNext != "" - printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) + printListPaginated(resp.Data, searchColumns, hasNext, resp.LinkNext, searchAll, summary, breadcrumbs) return nil }, } diff --git a/internal/commands/setup.go b/internal/commands/setup.go index ff06fe8..9277d8d 100644 --- a/internal/commands/setup.go +++ b/internal/commands/setup.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/basecamp/cli/output" "github.com/basecamp/fizzy-cli/internal/client" "github.com/basecamp/fizzy-cli/internal/config" "github.com/basecamp/fizzy-cli/internal/errors" @@ -36,6 +37,10 @@ func init() { } func runSetup(cmd *cobra.Command, args []string) error { + if IsMachineOutput() { + return output.ErrUsageHint("setup requires an interactive terminal", "Run without --agent/--json/--quiet or in a TTY") + } + fmt.Println() fmt.Println("Welcome to Fizzy CLI setup!") fmt.Println() @@ -238,9 +243,24 @@ func runSetup(cmd *cobra.Command, args []string) error { } if saveGlobal { + // Save token to credstore when available + credstoreSaved := false + if creds != nil { + if err := credsSaveToken(token); err != nil { + fmt.Printf("Warning: could not save token to credential store: %v\n", err) + } else { + credstoreSaved = true + } + } + // Load existing global config to preserve any other settings existingConfig := config.LoadGlobal() - existingConfig.Token = newConfig.Token + // Only clear YAML token when credstore save actually succeeded + if credstoreSaved { + existingConfig.Token = "" + } else { + existingConfig.Token = newConfig.Token + } existingConfig.Account = newConfig.Account existingConfig.Board = newConfig.Board if newConfig.APIURL != "" { diff --git a/internal/commands/skill.go b/internal/commands/skill.go index 3705897..0bfe546 100644 --- a/internal/commands/skill.go +++ b/internal/commands/skill.go @@ -1,20 +1,17 @@ package commands import ( - "context" "fmt" - "io" - "net/http" "os" "path/filepath" "strings" "github.com/basecamp/cli/output" + "github.com/basecamp/fizzy-cli/internal/skills" "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) -const skillSourceURL = "https://raw.githubusercontent.com/basecamp/fizzy-cli/refs/heads/master/skills/fizzy/SKILL.md" const skillFilename = "SKILL.md" // SkillLocation represents a predefined skill installation location @@ -64,6 +61,10 @@ func init() { } func runSkill(cmd *cobra.Command, args []string) error { + if IsMachineOutput() { + return output.ErrUsageHint("skill install requires an interactive terminal", "Run without --agent/--json/--quiet or in a TTY") + } + fmt.Println() fmt.Println("Fizzy Skill Installation") fmt.Println() @@ -129,17 +130,9 @@ func runSkill(cmd *cobra.Command, args []string) error { } } - // Download and install - fmt.Print("Downloading skill file... ") - content, err := downloadSkillFile() - if err != nil { - fmt.Println("✗") - return &output.Error{Code: output.CodeAPI, Message: fmt.Sprintf("downloading skill file: %v", err)} - } - fmt.Println("✓") - + // Install embedded skill file fmt.Print("Installing to " + selectedPath + "... ") - err = installSkillFile(expandedPath, content) + err = installSkillFile(expandedPath, skills.Content) if err != nil { fmt.Println("✗") return &output.Error{Code: output.CodeAPI, Message: fmt.Sprintf("installing skill file: %v", err)} @@ -214,30 +207,6 @@ func fileExists(path string) bool { return err == nil } -// downloadSkillFile downloads the SKILL.md content from GitHub -func downloadSkillFile() ([]byte, error) { - req, err := http.NewRequestWithContext(context.Background(), "GET", skillSourceURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - content, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - return content, nil -} - // installSkillFile writes the skill file to the specified path func installSkillFile(path string, content []byte) error { // Create directory if it doesn't exist diff --git a/internal/commands/step.go b/internal/commands/step.go index 5e1ab2d..c205bff 100644 --- a/internal/commands/step.go +++ b/internal/commands/step.go @@ -44,7 +44,7 @@ var stepShowCmd = &cobra.Command{ breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printDetail(resp.Data, "", breadcrumbs) return nil }, } @@ -99,14 +99,14 @@ var stepCreateCmd = &cobra.Command{ if resp.Location != "" { followResp, err := client.FollowLocation(resp.Location) if err == nil && followResp != nil { - printSuccessWithLocationAndBreadcrumbs(followResp.Data, resp.Location, breadcrumbs) + printMutationWithLocation(followResp.Data, resp.Location, breadcrumbs) return nil } printSuccessWithLocation(resp.Location) return nil } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -162,7 +162,7 @@ var stepUpdateCmd = &cobra.Command{ breadcrumb("card", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printMutation(resp.Data, "", breadcrumbs) return nil }, } @@ -198,7 +198,7 @@ var stepDeleteCmd = &cobra.Command{ breadcrumb("step", fmt.Sprintf("fizzy step create --card %s --content \"text\"", cardNumber), "Add step"), } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "deleted": true, }, "", breadcrumbs) return nil diff --git a/internal/commands/surface_test.go b/internal/commands/surface_test.go new file mode 100644 index 0000000..58ee906 --- /dev/null +++ b/internal/commands/surface_test.go @@ -0,0 +1,114 @@ +package commands + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/basecamp/cli/surface" + "github.com/spf13/cobra" +) + +func TestSurfaceSnapshot(t *testing.T) { + // Ensure Cobra has initialized all default help flags and commands + initAllHelpFlags(rootCmd) + + // Generate fresh snapshot from the command tree + fresh := surface.SnapshotString(rootCmd) + + // Read golden file + _, thisFile, _, _ := runtime.Caller(0) + goldenPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "SURFACE.txt") + golden, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("Failed to read SURFACE.txt (run 'make surface-snapshot' to generate): %v", err) + } + + goldenStr := strings.TrimSpace(string(golden)) + freshStr := strings.TrimSpace(fresh) + + if goldenStr != freshStr { + // Show the diff for debugging + oldEntries := surface.Snapshot(rootCmd) + goldenEntries := parseSurfaceEntries(goldenStr) + diff := surface.Diff(goldenEntries, oldEntries) + + var sb strings.Builder + sb.WriteString("SURFACE.txt is out of date. Run 'make surface-snapshot' to regenerate.\n\n") + if len(diff.Added) > 0 { + sb.WriteString("Added:\n") + for _, e := range diff.Added { + sb.WriteString(" + " + e.String() + "\n") + } + } + if len(diff.Removed) > 0 { + sb.WriteString("Removed (BREAKING):\n") + for _, e := range diff.Removed { + sb.WriteString(" - " + e.String() + "\n") + } + } + t.Fatal(sb.String()) + } +} + +// initAllHelpFlags recursively initializes help flags on all commands. +// Cobra adds --help lazily; this ensures deterministic snapshots. +func initAllHelpFlags(cmd *cobra.Command) { + cmd.InitDefaultHelpCmd() + cmd.InitDefaultHelpFlag() + for _, sub := range cmd.Commands() { + initAllHelpFlags(sub) + } +} + +// parseSurfaceEntries parses a surface snapshot string back into entries for diffing. +func parseSurfaceEntries(s string) []surface.Entry { + var entries []surface.Entry + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, " ", 2) + if len(parts) < 2 { + continue + } + kind := surface.EntryKind(parts[0]) + rest := parts[1] + + switch kind { + case surface.KindCmd: + entries = append(entries, surface.Entry{Kind: kind, Path: rest}) + case surface.KindFlag: + // FLAG path --name type=flagtype + flagParts := strings.Fields(rest) + if len(flagParts) >= 3 { + path := flagParts[0] + name := strings.TrimPrefix(flagParts[1], "--") + flagType := strings.TrimPrefix(flagParts[2], "type=") + // Path may be multi-word — find the -- prefix + for i, p := range flagParts { + if strings.HasPrefix(p, "--") { + path = strings.Join(flagParts[:i], " ") + name = strings.TrimPrefix(flagParts[i], "--") + if i+1 < len(flagParts) { + flagType = strings.TrimPrefix(flagParts[i+1], "type=") + } + break + } + } + entries = append(entries, surface.Entry{Kind: kind, Path: path, Name: name, FlagType: flagType}) + } + case surface.KindSub: + subParts := strings.Fields(rest) + if len(subParts) >= 2 { + name := subParts[len(subParts)-1] + path := strings.Join(subParts[:len(subParts)-1], " ") + entries = append(entries, surface.Entry{Kind: kind, Path: path, Name: name}) + } + } + } + return entries +} diff --git a/internal/commands/tag.go b/internal/commands/tag.go index c6280be..40df008 100644 --- a/internal/commands/tag.go +++ b/internal/commands/tag.go @@ -25,6 +25,9 @@ var tagListCmd = &cobra.Command{ if err := requireAuthAndAccount(); err != nil { return err } + if err := checkLimitAll(tagListAll); err != nil { + return err + } client := getClient() path := "/tags.json" @@ -64,7 +67,7 @@ var tagListCmd = &cobra.Command{ breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy tag list --page %d", nextPage), "Next page")) } - printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) + printListPaginated(resp.Data, tagColumns, hasNext, resp.LinkNext, tagListAll, summary, breadcrumbs) return nil }, } diff --git a/internal/commands/upload.go b/internal/commands/upload.go index 3a735ff..cbd4346 100644 --- a/internal/commands/upload.go +++ b/internal/commands/upload.go @@ -36,7 +36,7 @@ var uploadFileCmd = &cobra.Command{ return err } - printSuccess(resp.Data) + printMutation(resp.Data, "", nil) return nil }, } diff --git a/internal/commands/user.go b/internal/commands/user.go index 3345296..890a499 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -25,6 +25,9 @@ var userListCmd = &cobra.Command{ if err := requireAuthAndAccount(); err != nil { return err } + if err := checkLimitAll(userListAll); err != nil { + return err + } client := getClient() path := "/users.json" @@ -64,7 +67,7 @@ var userListCmd = &cobra.Command{ breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy user list --page %d", nextPage), "Next page")) } - printSuccessWithPaginationAndBreadcrumbs(resp.Data, hasNext, resp.LinkNext, summary, breadcrumbs) + printListPaginated(resp.Data, userColumns, hasNext, resp.LinkNext, userListAll, summary, breadcrumbs) return nil }, } @@ -93,7 +96,7 @@ var userShowCmd = &cobra.Command{ breadcrumb("assign", fmt.Sprintf("fizzy card assign --user %s", userID), "Assign to card"), } - printSuccessWithBreadcrumbs(resp.Data, "", breadcrumbs) + printDetail(resp.Data, "", breadcrumbs) return nil }, } @@ -140,7 +143,7 @@ var userUpdateCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil } @@ -164,7 +167,7 @@ var userUpdateCmd = &cobra.Command{ if data == nil { data = map[string]any{} } - printSuccessWithBreadcrumbs(data, "", breadcrumbs) + printMutation(data, "", breadcrumbs) return nil }, } @@ -191,7 +194,7 @@ var userDeactivateCmd = &cobra.Command{ breadcrumb("people", "fizzy user list", "List users"), } - printSuccessWithBreadcrumbs(map[string]any{ + printMutation(map[string]any{ "deactivated": true, }, "", breadcrumbs) return nil diff --git a/internal/config/config.go b/internal/config/config.go index 6601cbd..2441cac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -180,6 +181,16 @@ func ensureAPIURL(cfg *Config) { if strings.TrimSpace(cfg.APIURL) == "" { cfg.APIURL = DefaultAPIURL } + + // Warn about non-HTTPS URLs (except localhost/loopback for development) + if strings.HasPrefix(cfg.APIURL, "http://") { + if u, err := url.Parse(cfg.APIURL); err == nil { + host := u.Hostname() // strips port and IPv6 brackets + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + fmt.Fprintf(os.Stderr, "Warning: API URL %q uses HTTP. HTTPS is recommended for security.\n", cfg.APIURL) + } + } + } } // ConfigPath returns the path to the global config file (creating directory if needed). diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9962f93..76905ca 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "os" "path/filepath" "strings" @@ -673,6 +674,94 @@ func TestSetTestConfigDir(t *testing.T) { } } +func TestHTTPWarning(t *testing.T) { + t.Run("warns on non-localhost HTTP URL", func(t *testing.T) { + // Capture stderr + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + cfg := &Config{APIURL: "http://example.com/api"} + ensureAPIURL(cfg) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + if !strings.Contains(buf.String(), "Warning") { + t.Error("expected HTTPS warning for http://example.com") + } + }) + + t.Run("no warning for localhost", func(t *testing.T) { + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + cfg := &Config{APIURL: "http://localhost:3000"} + ensureAPIURL(cfg) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + if strings.Contains(buf.String(), "Warning") { + t.Error("should not warn for localhost") + } + }) + + t.Run("no warning for 127.0.0.1", func(t *testing.T) { + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + cfg := &Config{APIURL: "http://127.0.0.1:3000"} + ensureAPIURL(cfg) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + if strings.Contains(buf.String(), "Warning") { + t.Error("should not warn for 127.0.0.1") + } + }) + + t.Run("no warning for IPv6 loopback with port", func(t *testing.T) { + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + cfg := &Config{APIURL: "http://[::1]:3000"} + ensureAPIURL(cfg) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + if strings.Contains(buf.String(), "Warning") { + t.Error("should not warn for [::1]:3000") + } + }) + + t.Run("no warning for HTTPS", func(t *testing.T) { + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + cfg := &Config{APIURL: "https://example.com"} + ensureAPIURL(cfg) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + if strings.Contains(buf.String(), "Warning") { + t.Error("should not warn for HTTPS") + } + }) +} + func TestFullConfigPriorityChain(t *testing.T) { // This test validates the full priority chain: // CLI flags > env vars > local config > global config > defaults diff --git a/internal/errors/errors.go b/internal/errors/errors.go index c6ed72f..edf3b0c 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -41,9 +41,11 @@ func NewInvalidArgsError(message string) *CLIError { return &output.Error{Code: output.CodeUsage, Message: message} } -// NewAuthError creates an authentication error. +// NewAuthError creates an authentication error with a fizzy-specific hint. func NewAuthError(message string) *CLIError { - return &output.Error{Code: output.CodeAuth, Message: message} + e := output.ErrAuth(message) + e.Hint = "Run 'fizzy auth login TOKEN' or set FIZZY_TOKEN" + return e } // NewForbiddenError creates a permission denied error. @@ -61,16 +63,19 @@ func NewValidationError(message string) *CLIError { return &output.Error{Code: output.CodeAPI, Message: message, HTTPStatus: 422} } -// NewNetworkError creates a network error. +// NewNetworkError creates a network error with retryable hint. func NewNetworkError(message string) *CLIError { - return &output.Error{Code: output.CodeNetwork, Message: message} + e := output.ErrNetwork(fmt.Errorf("%s", message)) + return e } // FromHTTPStatus creates an appropriate error from an HTTP status code. func FromHTTPStatus(status int, message string) *CLIError { switch status { case 401: - return NewAuthError(message) + e := output.ErrAuth(message) + e.Hint = "Run 'fizzy auth login TOKEN' or set FIZZY_TOKEN" + return e case 403: return NewForbiddenError(message) case 404: @@ -83,6 +88,14 @@ func FromHTTPStatus(status int, message string) *CLIError { e.Message = message } return e + case 502, 503, 504: + return &output.Error{ + Code: output.CodeAPI, + Message: fmt.Sprintf("Request failed: %d %s", status, message), + HTTPStatus: status, + Retryable: true, + Hint: "The server returned a temporary error. Try again in a moment.", + } default: return &output.Error{ Code: output.CodeAPI, diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 5ced1f6..9293fe4 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -97,6 +97,9 @@ func TestNewAuthError(t *testing.T) { if err.ExitCode() != ExitAuth { t.Errorf("expected exit code %d, got %d", ExitAuth, err.ExitCode()) } + if err.Hint == "" { + t.Error("auth errors should have a hint") + } } func TestNewForbiddenError(t *testing.T) { @@ -156,12 +159,12 @@ func TestNewNetworkError(t *testing.T) { if err.Code != output.CodeNetwork { t.Errorf("expected code %q, got %q", output.CodeNetwork, err.Code) } - if err.Message != "connection failed" { - t.Errorf("expected message 'connection failed', got %q", err.Message) - } if err.ExitCode() != ExitNetwork { t.Errorf("expected exit code %d, got %d", ExitNetwork, err.ExitCode()) } + if !err.Retryable { + t.Error("network errors should be retryable") + } } func TestNewInvalidArgsError(t *testing.T) { @@ -208,3 +211,17 @@ func TestFromHTTPStatus(t *testing.T) { }) } } + +func TestFromHTTPStatus401HasHint(t *testing.T) { + err := FromHTTPStatus(401, "Unauthorized") + if err.Hint == "" { + t.Error("401 error should have a hint") + } +} + +func TestFromHTTPStatus429IsRetryable(t *testing.T) { + err := FromHTTPStatus(429, "Too Many Requests") + if !err.Retryable { + t.Error("429 error should be retryable") + } +} diff --git a/internal/render/markdown.go b/internal/render/markdown.go new file mode 100644 index 0000000..adedea2 --- /dev/null +++ b/internal/render/markdown.go @@ -0,0 +1,85 @@ +package render + +import ( + "fmt" + "strings" +) + +// MarkdownList renders a slice of maps as a GFM table. +func MarkdownList(data []map[string]any, cols Columns, summary string) string { + if len(data) == 0 { + if summary != "" { + return summary + "\n" + } + return "No results.\n" + } + + var sb strings.Builder + if summary != "" { + sb.WriteString(summary) + sb.WriteString("\n\n") + } + + // Header row + headers := make([]string, len(cols)) + seps := make([]string, len(cols)) + for i, c := range cols { + headers[i] = c.Header + seps[i] = "---" + } + sb.WriteString("| ") + sb.WriteString(strings.Join(headers, " | ")) + sb.WriteString(" |\n") + sb.WriteString("| ") + sb.WriteString(strings.Join(seps, " | ")) + sb.WriteString(" |\n") + + // Data rows + for _, item := range data { + vals := make([]string, len(cols)) + for i, c := range cols { + vals[i] = escapeMarkdown(extractString(item, c.Field)) + } + sb.WriteString("| ") + sb.WriteString(strings.Join(vals, " | ")) + sb.WriteString(" |\n") + } + return sb.String() +} + +// MarkdownDetail renders a single map as bold-label: value pairs. +func MarkdownDetail(data map[string]any, summary string) string { + if data == nil { + return "No data.\n" + } + + keys := sortedKeys(data) + + var sb strings.Builder + if summary != "" { + sb.WriteString(summary) + sb.WriteString("\n\n") + } + + for _, k := range keys { + val := formatValue(data[k]) + fmt.Fprintf(&sb, "**%s:** %s\n", k, escapeMarkdown(val)) + } + return sb.String() +} + +// MarkdownSummary renders a single-line summary message for mutations. +func MarkdownSummary(data map[string]any, summary string) string { + if summary != "" { + return fmt.Sprintf("> %s\n", summary) + } + if data == nil { + return "> Done\n" + } + return MarkdownDetail(data, "") +} + +// escapeMarkdown escapes pipe characters in markdown table cells. +func escapeMarkdown(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} diff --git a/internal/render/markdown_test.go b/internal/render/markdown_test.go new file mode 100644 index 0000000..61b7d9b --- /dev/null +++ b/internal/render/markdown_test.go @@ -0,0 +1,72 @@ +package render + +import ( + "strings" + "testing" +) + +func TestMarkdownListEmpty(t *testing.T) { + result := MarkdownList(nil, Columns{{Header: "ID", Field: "id"}}, "") + if result != "No results.\n" { + t.Errorf("expected 'No results.', got %q", result) + } +} + +func TestMarkdownListRendersTable(t *testing.T) { + data := []map[string]any{ + {"id": "1", "name": "Alpha"}, + {"id": "2", "name": "Beta"}, + } + cols := Columns{ + {Header: "ID", Field: "id"}, + {Header: "Name", Field: "name"}, + } + result := MarkdownList(data, cols, "2 boards") + if !strings.Contains(result, "| ID | Name |") { + t.Errorf("expected header row, got:\n%s", result) + } + if !strings.Contains(result, "| --- | --- |") { + t.Errorf("expected separator row, got:\n%s", result) + } + if !strings.Contains(result, "| 1 | Alpha |") { + t.Errorf("expected data row for Alpha, got:\n%s", result) + } + if !strings.Contains(result, "| 2 | Beta |") { + t.Errorf("expected data row for Beta, got:\n%s", result) + } + if !strings.Contains(result, "2 boards") { + t.Error("expected summary in output") + } +} + +func TestMarkdownListEscapesPipes(t *testing.T) { + data := []map[string]any{ + {"name": "foo|bar"}, + } + cols := Columns{{Header: "Name", Field: "name"}} + result := MarkdownList(data, cols, "") + if strings.Contains(result, "foo|bar") { + t.Error("pipe character should be escaped") + } + if !strings.Contains(result, `foo\|bar`) { + t.Errorf("expected escaped pipe, got:\n%s", result) + } +} + +func TestMarkdownDetailNil(t *testing.T) { + result := MarkdownDetail(nil, "") + if result != "No data.\n" { + t.Errorf("expected 'No data.', got %q", result) + } +} + +func TestMarkdownDetailRendersKV(t *testing.T) { + data := map[string]any{"id": "42", "name": "Test"} + result := MarkdownDetail(data, "") + if !strings.Contains(result, "**id:** 42") { + t.Errorf("expected bold key, got:\n%s", result) + } + if !strings.Contains(result, "**name:** Test") { + t.Errorf("expected bold key, got:\n%s", result) + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..97af3af --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,188 @@ +// Package render provides styled and markdown output rendering for the Fizzy CLI. +// The shared output.Writer falls back to JSON for FormatStyled/FormatMarkdown; +// this package supplies the app-specific rendering. +package render + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +// Column maps a display header to a JSON field name. +type Column struct { + Header string + Field string +} + +// Columns is a slice of Column specs for table rendering. +type Columns []Column + +// headerStyle is the style for table headers in styled output. +var headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + +// StyledList renders a slice of maps as a styled terminal table. +func StyledList(data []map[string]any, cols Columns, summary string) string { + if len(data) == 0 { + if summary != "" { + return summary + "\n" + } + return "No results.\n" + } + + headers := make([]string, len(cols)) + for i, c := range cols { + headers[i] = c.Header + } + + rows := make([][]string, 0, len(data)) + for _, item := range data { + row := make([]string, len(cols)) + for i, c := range cols { + row[i] = extractString(item, c.Field) + } + rows = append(rows, row) + } + + t := table.New(). + Headers(headers...). + Rows(rows...). + BorderLeft(false). + BorderRight(false). + BorderTop(false). + BorderBottom(false). + BorderColumn(false). + BorderHeader(true). + Border(lipgloss.NormalBorder()). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return headerStyle + } + return lipgloss.NewStyle() + }) + + var sb strings.Builder + if summary != "" { + sb.WriteString(summary) + sb.WriteString("\n\n") + } + sb.WriteString(t.String()) + sb.WriteString("\n") + return sb.String() +} + +// StyledDetail renders a single map as styled key-value pairs. +func StyledDetail(data map[string]any, summary string) string { + if data == nil { + return "No data.\n" + } + + keys := sortedKeys(data) + + var sb strings.Builder + if summary != "" { + sb.WriteString(summary) + sb.WriteString("\n\n") + } + + labelStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + for _, k := range keys { + label := labelStyle.Render(k + ":") + val := formatValue(data[k]) + fmt.Fprintf(&sb, "%s %s\n", label, val) + } + return sb.String() +} + +// extractString gets a field value from a map and converts it to a display string. +// Supports dot-separated paths for nested fields (e.g., "column.name"). +func extractString(m map[string]any, field string) string { + parts := strings.SplitN(field, ".", 2) + val, ok := m[parts[0]] + if !ok { + return "" + } + + if len(parts) == 2 { + if nested, ok := val.(map[string]any); ok { + return extractString(nested, parts[1]) + } + return "" + } + + return formatValue(val) +} + +// formatValue converts any value to a display string. +func formatValue(v any) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case bool: + if val { + return "yes" + } + return "no" + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + case map[string]any: + // Try common display fields + for _, key := range []string{"name", "title", "slug", "id"} { + if s, ok := val[key]; ok { + return formatValue(s) + } + } + return fmt.Sprintf("%v", val) + case []any: + return fmt.Sprintf("[%d items]", len(val)) + default: + return fmt.Sprintf("%v", val) + } +} + +// StyledSummary renders a single-line summary message for mutations. +func StyledSummary(data map[string]any, summary string) string { + if summary != "" { + return lipgloss.NewStyle().Bold(true).Render("✓ "+summary) + "\n" + } + if data == nil { + return lipgloss.NewStyle().Bold(true).Render("✓ Done") + "\n" + } + return StyledDetail(data, "") +} + +func sortedKeys(m map[string]any) []string { + // Put common identifying fields first, then alphabetical + priority := map[string]int{ + "id": 0, "number": 1, "name": 2, "title": 3, + "email": 4, "status": 5, + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + pi, oki := priority[keys[i]] + pj, okj := priority[keys[j]] + if oki && okj { + return pi < pj + } + if oki { + return true + } + if okj { + return false + } + return keys[i] < keys[j] + }) + return keys +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..8817949 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,113 @@ +package render + +import ( + "strings" + "testing" +) + +func TestStyledListEmpty(t *testing.T) { + result := StyledList(nil, Columns{{Header: "ID", Field: "id"}}, "") + if result != "No results.\n" { + t.Errorf("expected 'No results.', got %q", result) + } +} + +func TestStyledListEmptyWithSummary(t *testing.T) { + result := StyledList(nil, Columns{{Header: "ID", Field: "id"}}, "0 boards") + if result != "0 boards\n" { + t.Errorf("expected '0 boards', got %q", result) + } +} + +func TestStyledListRendersTable(t *testing.T) { + data := []map[string]any{ + {"id": "1", "name": "Alpha"}, + {"id": "2", "name": "Beta"}, + } + cols := Columns{ + {Header: "ID", Field: "id"}, + {Header: "Name", Field: "name"}, + } + result := StyledList(data, cols, "2 boards") + if !strings.Contains(result, "Alpha") { + t.Error("expected output to contain 'Alpha'") + } + if !strings.Contains(result, "Beta") { + t.Error("expected output to contain 'Beta'") + } + if !strings.Contains(result, "2 boards") { + t.Error("expected output to contain summary") + } +} + +func TestStyledListNestedField(t *testing.T) { + data := []map[string]any{ + {"id": "1", "column": map[string]any{"name": "Inbox"}}, + } + cols := Columns{{Header: "Column", Field: "column.name"}} + result := StyledList(data, cols, "") + if !strings.Contains(result, "Inbox") { + t.Error("expected nested field 'Inbox' in output") + } +} + +func TestStyledDetailNil(t *testing.T) { + result := StyledDetail(nil, "") + if result != "No data.\n" { + t.Errorf("expected 'No data.', got %q", result) + } +} + +func TestStyledDetailRendersKV(t *testing.T) { + data := map[string]any{"id": "42", "name": "Test Board"} + result := StyledDetail(data, "Board: Test Board") + if !strings.Contains(result, "42") { + t.Error("expected output to contain '42'") + } + if !strings.Contains(result, "Test Board") { + t.Error("expected output to contain 'Test Board'") + } +} + +func TestFormatValue(t *testing.T) { + tests := []struct { + input any + expected string + }{ + {nil, ""}, + {"hello", "hello"}, + {true, "yes"}, + {false, "no"}, + {float64(42), "42"}, + {float64(3.14), "3.14"}, + {[]any{1, 2, 3}, "[3 items]"}, + {map[string]any{"name": "Foo"}, "Foo"}, + {map[string]any{"id": "123"}, "123"}, + } + for _, tt := range tests { + got := formatValue(tt.input) + if got != tt.expected { + t.Errorf("formatValue(%v) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestSortedKeys(t *testing.T) { + m := map[string]any{ + "zzz": 1, + "id": 2, + "name": 3, + "aaa": 4, + "title": 5, + "number": 6, + } + keys := sortedKeys(m) + // Priority keys should come first: id, number, name, title, then alphabetical + expected := []string{"id", "number", "name", "title", "aaa", "zzz"} + for i, k := range expected { + if keys[i] != k { + t.Errorf("position %d: expected %q, got %q (full: %v)", i, k, keys[i], keys) + break + } + } +} diff --git a/internal/skills/SKILL.md b/internal/skills/SKILL.md new file mode 100644 index 0000000..1971103 --- /dev/null +++ b/internal/skills/SKILL.md @@ -0,0 +1,978 @@ +--- +name: fizzy +description: | + Interact with Fizzy via the Fizzy CLI. Manage boards, cards, columns, comments, + steps, reactions, tags, users, notifications, and pins. Use for ANY Fizzy question or action. +triggers: + # Direct invocations + - fizzy + - /fizzy + # Resource actions + - fizzy board + - fizzy card + - fizzy column + - fizzy comment + - fizzy step + - fizzy reaction + - fizzy tag + - fizzy notification + # Common actions + - link to fizzy + - track in fizzy + - create card + - close card + - move card + - assign card + - add comment + - add step + - search cards + # Search and discovery + - search fizzy + - find in fizzy + - check fizzy + - list fizzy + - show fizzy + - get from fizzy + # Questions + - what's in fizzy + - what fizzy + - how do I fizzy + # My work + - my cards + - my tasks + - my board + - assigned to me + - pinned cards + # URLs + - fizzy.do + - app.fizzy.do +invocable: true +argument-hint: "[action] [args...]" +--- + +# /fizzy - Fizzy Workflow Command + +Full CLI coverage: boards, cards, columns, comments, steps, reactions, tags, users, notifications, pins, search, and board migration. + +## Agent Invariants + +**MUST follow these rules:** + +1. **Cards use NUMBER, not ID** — `fizzy card show 42` uses the card number. Other resources use their `id` field. +2. **Parse JSON with jq** to reduce token output — `fizzy card list | jq '[.data[] | {number, title}]'` +3. **Check breadcrumbs** in responses for available next actions with pre-filled values +4. **Check for board context** via `.fizzy.yaml` or `--board` flag before listing cards +5. **Rich text fields accept HTML** — use `

` tags for paragraphs, `` for inline images +6. **Card description is a string**, but comment body is a nested object — `.description` vs `.body.plain_text` + +## Decision Trees + +### Finding Content + +``` +Need to find something? +├── Know the board? → fizzy card list --board +├── Full-text search? → fizzy search "query" +├── Filter by status? → fizzy card list --indexed-by closed|not_now|golden|stalled +├── Filter by person? → fizzy card list --assignee +├── Filter by time? → fizzy card list --created today|thisweek|thismonth +└── Cross-board? → fizzy search "query" (searches all boards) +``` + +### Modifying Content + +``` +Want to change something? +├── Move to column? → fizzy card column --column +├── Change status? → fizzy card close|reopen|postpone +├── Assign? → fizzy card assign --user +├── Comment? → fizzy comment create --card --body "text" +├── Add step? → fizzy step create --card --content "text" +└── Move to board? → fizzy card move --to +``` + +## Quick Reference + +| Resource | List | Show | Create | Update | Delete | Other | +|----------|------|------|--------|--------|--------|-------| +| board | `board list` | `board show ID` | `board create` | `board update ID` | `board delete ID` | `migrate board ID` | +| card | `card list` | `card show NUMBER` | `card create` | `card update NUMBER` | `card delete NUMBER` | `card move NUMBER` | +| search | `search QUERY` | - | - | - | - | - | +| column | `column list --board ID` | `column show ID --board ID` | `column create` | `column update ID` | `column delete ID` | - | +| comment | `comment list --card NUMBER` | `comment show ID --card NUMBER` | `comment create` | `comment update ID` | `comment delete ID` | `comment attachments show --card NUMBER` | +| step | - | `step show ID --card NUMBER` | `step create` | `step update ID` | `step delete ID` | - | +| reaction | `reaction list` | - | `reaction create` | - | `reaction delete ID` | - | +| tag | `tag list` | - | - | - | - | - | +| user | `user list` | `user show ID` | - | `user update ID` | - | `user deactivate ID` | +| notification | `notification list` | - | - | - | - | `notification tray`, `notification read-all` | +| pin | `pin list` | - | - | - | - | `card pin NUMBER`, `card unpin NUMBER` | + +--- + +## Global Flags + +All commands support: + +| Flag | Description | +|------|-------------| +| `--token TOKEN` | API access token | +| `--account SLUG` | Account slug (for multi-account users) | +| `--api-url URL` | API base URL (default: https://app.fizzy.do) | +| `--json` | JSON envelope output | +| `--quiet` | Raw JSON data without envelope | +| `--styled` | Human-readable styled output (tables, colors) | +| `--markdown` | GFM markdown output (for agents) | +| `--agent` | Agent mode (defaults to quiet; combinable with --json/--markdown) | +| `--ids-only` | Print one ID per line | +| `--count` | Print count of results | +| `--limit N` | Client-side truncation of list results | +| `--verbose` | Show request/response details | + +Output format defaults to auto-detection: styled for TTY, JSON for pipes/non-TTY. + +## Pagination + +List commands use `--page` for pagination and `--limit` for client-side truncation. + +```bash +# Get first page (default) +fizzy card list --page 1 + +# Limit to N results +fizzy card list --limit 5 + +# Fetch ALL pages at once +fizzy card list --all +``` + +Note: `--limit` and `--all` cannot be used together. + +**IMPORTANT:** The `--all` flag controls pagination only - it fetches all pages of results for your current filter. It does NOT change which cards are included. By default, `card list` returns only open cards. See [Card Statuses](#card-statuses) for how to fetch closed or postponed cards. + +Commands supporting `--all` and `--page`: +- `board list` +- `card list` +- `search` +- `comment list` +- `tag list` +- `user list` +- `notification list` + +--- + +## Configuration + +``` +~/.config/fizzy/ # Global config (or ~/.fizzy/) +└── config.yaml # Token, account, API URL, default board + +.fizzy.yaml # Per-repo config (committed to git) +``` + +**Per-repo config:** `.fizzy.yaml` +```yaml +account: 123456789 +board: 03foq1hqmyy91tuyz3ghugg6c +``` + +**Priority (highest to lowest):** +1. CLI flags (`--token`, `--account`, `--api-url`, `--board`) +2. Environment variables (`FIZZY_TOKEN`, `FIZZY_ACCOUNT`, `FIZZY_API_URL`, `FIZZY_BOARD`) +3. Local project config (`.fizzy.yaml`) +4. Global config (`~/.config/fizzy/config.yaml` or `~/.fizzy/config.yaml`) + +**Check context:** +```bash +cat .fizzy.yaml 2>/dev/null || echo "No project configured" +``` + +**Setup:** +```bash +fizzy setup # Interactive wizard +fizzy auth login TOKEN # Save token directly +fizzy auth status # Check auth status +fizzy identity show # Show accounts +``` + +--- + +## Response Structure + +All responses follow this structure: + +```json +{ + "ok": true, + "data": { ... }, // Single object or array + "summary": "4 boards", // Human-readable description + "breadcrumbs": [ ... ], // Contextual next actions (omitted when empty) + "context": { ... }, // Location, pagination, and other context (omitted when empty) + "meta": { + } +} +``` + +**Summary field formats:** +| Command | Example Summary | +|---------|-----------------| +| `board list` | "5 boards" | +| `board show ID` | "Board: Engineering" | +| `card list` | "42 cards (page 1)" or "42 cards (all)" | +| `card show 123` | "Card #123: Fix login bug" | +| `search "bug"` | "7 results for \"bug\"" | +| `notification list` | "8 notifications (3 unread)" | + +**List responses with pagination:** +```json +{ + "ok": true, + "data": [ ... ], + "summary": "10 cards (page 1)", + "context": { + "pagination": { + "has_next": true, + "next_url": "https://..." + } + } +} +``` + +**Breadcrumbs (contextual next actions):** + +Responses include a `breadcrumbs` array suggesting what you can do next. Each breadcrumb has: +- `action`: Short action name (e.g., "comment", "close", "assign") +- `cmd`: Ready-to-run command with actual values interpolated +- `description`: Human-readable description + +```bash +fizzy card show 42 | jq '.breadcrumbs' +``` + +```json +[ + {"action": "comment", "cmd": "fizzy comment create --card 42 --body \"text\"", "description": "Add comment"}, + {"action": "triage", "cmd": "fizzy card column 42 --column ", "description": "Move to column"}, + {"action": "close", "cmd": "fizzy card close 42", "description": "Close card"}, + {"action": "assign", "cmd": "fizzy card assign 42 --user ", "description": "Assign user"} +] +``` + +Use breadcrumbs to discover available actions without memorizing the full CLI. Values like card numbers and board IDs are pre-filled; placeholders like `` need to be replaced. + +**Create/update responses include location:** +```json +{ + "ok": true, + "data": { ... }, + "context": { + "location": "/6102600/cards/579.json" + } +} +``` + +--- + +## ID Formats + +**IMPORTANT:** Cards use TWO identifiers: + +| Field | Format | Use For | +|-------|--------|---------| +| `id` | `03fe4rug9kt1mpgyy51lq8i5i` | Internal ID (in JSON responses) | +| `number` | `579` | CLI commands (`card show`, `card update`, etc.) | + +**All card CLI commands use the card NUMBER, not the ID.** + +Other resources (boards, columns, comments, steps, reactions, users) use their `id` field. + +--- + +## Card Statuses + +Cards exist in different states. By default, `fizzy card list` returns **open cards only** (cards in triage or columns). To fetch cards in other states, use the `--indexed-by` or `--column` flags: + +| Status | How to fetch | Description | +|--------|--------------|-------------| +| Open (default) | `fizzy card list` | Cards in triage ("Maybe?") or any column | +| Closed/Done | `fizzy card list --indexed-by closed` | Completed cards | +| Not Now | `fizzy card list --indexed-by not_now` | Postponed cards | +| Golden | `fizzy card list --indexed-by golden` | Starred/important cards | +| Stalled | `fizzy card list --indexed-by stalled` | Cards with no recent activity | + +You can also use pseudo-columns: + +```bash +fizzy card list --column done --all # Same as --indexed-by closed +fizzy card list --column not-now --all # Same as --indexed-by not_now +fizzy card list --column maybe --all # Cards in triage (no column assigned) +``` + +**Fetching all cards on a board:** + +To get all cards regardless of status (for example, to build a complete board view), make separate queries: + +```bash +# Open cards (triage + columns) +fizzy card list --board BOARD_ID --all + +# Closed/Done cards +fizzy card list --board BOARD_ID --indexed-by closed --all + +# Optionally, Not Now cards +fizzy card list --board BOARD_ID --indexed-by not_now --all +``` + +--- + +## Common jq Patterns + +### Reducing Output + +```bash +# Card summary (most useful) +fizzy card list | jq '[.data[] | {number, title, status, board: .board.name}]' + +# First N items +fizzy card list | jq '.data[:5]' + +# Just IDs +fizzy board list | jq '[.data[].id]' + +# Specific fields from single item +fizzy card show 579 | jq '.data | {number, title, status, golden}' + +# Card with description length (description is a string, not object) +fizzy card show 579 | jq '.data | {number, title, desc_length: (.description | length)}' +``` + +### Filtering + +```bash +# Cards with a specific status +fizzy card list --all | jq '[.data[] | select(.status == "published")]' + +# Golden cards only +fizzy card list --indexed-by golden | jq '[.data[] | {number, title}]' + +# Cards with non-empty descriptions +fizzy card list | jq '[.data[] | select(.description | length > 0) | {number, title}]' + +# Cards with steps (must use card show, steps not in list) +fizzy card show 579 | jq '.data.steps' +``` + +### Extracting Nested Data + +```bash +# Comment text only (body.plain_text for comments) +fizzy comment list --card 579 | jq '[.data[].body.plain_text]' + +# Card description (just .description for cards - it's a string) +fizzy card show 579 | jq '.data.description' + +# Step completion status +fizzy card show 579 | jq '[.data.steps[] | {content, completed}]' +``` + +### Activity Analysis + +```bash +# Cards with steps count (requires card show for each) +fizzy card show 579 | jq '.data | {number, title, steps_count: (.steps | length)}' + +# Comments count for a card +fizzy comment list --card 579 | jq '.data | length' +``` + +--- + +## Command Reference + +### Identity + +```bash +fizzy identity show # Show your identity and accessible accounts +``` + +### Search + +Quick text search across cards. Multiple words are treated as separate terms (AND). + +```bash +fizzy search QUERY [flags] + --board ID # Filter by board + --assignee ID # Filter by assignee user ID + --tag ID # Filter by tag ID + --indexed-by LANE # Filter: all, closed, not_now, golden + --sort ORDER # Sort: newest, oldest, or latest (default) + --page N # Page number + --all # Fetch all pages +``` + +**Examples:** +```bash +fizzy search "bug" # Search for "bug" +fizzy search "login error" # Search for cards containing both "login" AND "error" +fizzy search "bug" --board BOARD_ID # Search within a specific board +fizzy search "bug" --indexed-by closed # Include closed cards +fizzy search "feature" --sort newest # Sort by newest first +``` + +### Boards + +```bash +fizzy board list [--page N] [--all] +fizzy board show BOARD_ID +fizzy board create --name "Name" [--all_access true/false] [--auto_postpone_period N] +fizzy board update BOARD_ID [--name "Name"] [--all_access true/false] [--auto_postpone_period N] +fizzy board delete BOARD_ID +``` + +### Board Migration + +Migrate boards between accounts (e.g., from personal to team account). + +```bash +fizzy migrate board BOARD_ID --from SOURCE_SLUG --to TARGET_SLUG [flags] + --include-images # Migrate card header images and inline attachments + --include-comments # Migrate card comments + --include-steps # Migrate card steps (to-do items) + --dry-run # Preview migration without making changes +``` + +**What gets migrated:** +- Board with same name +- All columns (preserving order and colors) +- All cards with titles, descriptions, timestamps, and tags +- Card states (closed, golden, column placement) +- Optional: header images, inline attachments, comments, and steps + +**What cannot be migrated:** +- Card creators (become the migrating user) +- Card numbers (new sequential numbers in target) +- Comment authors (become the migrating user) +- User assignments (team must reassign manually) + +**Requirements:** You must have API access to both source and target accounts. Verify with `fizzy identity show`. + +```bash +# Preview migration first +fizzy migrate board BOARD_ID --from personal --to team-account --dry-run + +# Basic migration +fizzy migrate board BOARD_ID --from personal --to team-account + +# Full migration with all content +fizzy migrate board BOARD_ID --from personal --to team-account \ + --include-images --include-comments --include-steps +``` + +### Cards + +#### Listing & Viewing + +```bash +fizzy card list [flags] + --board ID # Filter by board + --column ID # Filter by column ID or pseudo: not-now, maybe, done + --assignee ID # Filter by assignee user ID + --tag ID # Filter by tag ID + --indexed-by LANE # Filter: all, closed, not_now, stalled, postponing_soon, golden + --search "terms" # Search by text (space-separated for multiple terms) + --sort ORDER # Sort: newest, oldest, or latest (default) + --creator ID # Filter by creator user ID + --closer ID # Filter by user who closed the card + --unassigned # Only show unassigned cards + --created PERIOD # Filter by creation: today, yesterday, thisweek, lastweek, thismonth, lastmonth + --closed PERIOD # Filter by closure: today, yesterday, thisweek, lastweek, thismonth, lastmonth + --page N # Page number + --all # Fetch all pages + +fizzy card show CARD_NUMBER # Show card details (includes steps) +``` + +#### Creating & Updating + +```bash +fizzy card create --board ID --title "Title" [flags] + --description "HTML" # Card description (HTML) + --description_file PATH # Read description from file + --image SIGNED_ID # Header image (use signed_id from upload) + --tag-ids "id1,id2" # Comma-separated tag IDs + --created-at TIMESTAMP # Custom created_at + +fizzy card update CARD_NUMBER [flags] + --title "Title" + --description "HTML" + --description_file PATH + --image SIGNED_ID + --created-at TIMESTAMP + +fizzy card delete CARD_NUMBER +``` + +#### Status Changes + +```bash +fizzy card close CARD_NUMBER # Close card (sets closed: true) +fizzy card reopen CARD_NUMBER # Reopen closed card +fizzy card postpone CARD_NUMBER # Move to Not Now lane +fizzy card untriage CARD_NUMBER # Remove from column, back to triage +``` + +**Note:** Card `status` field stays "published" for active cards. Use: +- `closed: true/false` to check if closed +- `--indexed-by not_now` to find postponed cards +- `--indexed-by closed` to find closed cards + +#### Actions + +```bash +fizzy card column CARD_NUMBER --column ID # Move to column (use column ID or: maybe, not-now, done) +fizzy card move CARD_NUMBER --to BOARD_ID # Move card to a different board +fizzy card assign CARD_NUMBER --user ID # Toggle user assignment +fizzy card self-assign CARD_NUMBER # Toggle current user's assignment +fizzy card tag CARD_NUMBER --tag "name" # Toggle tag (creates tag if needed) +fizzy card watch CARD_NUMBER # Subscribe to notifications +fizzy card unwatch CARD_NUMBER # Unsubscribe +fizzy card pin CARD_NUMBER # Pin card for quick access +fizzy card unpin CARD_NUMBER # Unpin card +fizzy card golden CARD_NUMBER # Mark as golden/starred +fizzy card ungolden CARD_NUMBER # Remove golden status +fizzy card image-remove CARD_NUMBER # Remove header image +``` + +#### Attachments + +```bash +fizzy card attachments show CARD_NUMBER [--include-comments] # List attachments +fizzy card attachments download CARD_NUMBER [INDEX] [--include-comments] # Download (1-based index) + -o, --output FILENAME # Exact name (single) or prefix (multiple: test_1.png, test_2.png) +``` + +### Columns + +Boards have pseudo columns by default: `not-now`, `maybe`, `done` + +```bash +fizzy column list --board ID +fizzy column show COLUMN_ID --board ID +fizzy column create --board ID --name "Name" [--color HEX] +fizzy column update COLUMN_ID --board ID [--name "Name"] [--color HEX] +fizzy column delete COLUMN_ID --board ID +``` + +### Comments + +```bash +fizzy comment list --card NUMBER [--page N] [--all] +fizzy comment show COMMENT_ID --card NUMBER +fizzy comment create --card NUMBER --body "HTML" [--body_file PATH] [--created-at TIMESTAMP] +fizzy comment update COMMENT_ID --card NUMBER [--body "HTML"] [--body_file PATH] +fizzy comment delete COMMENT_ID --card NUMBER +``` + +#### Comment Attachments + +```bash +fizzy comment attachments show --card NUMBER # List attachments in comments +fizzy comment attachments download --card NUMBER [INDEX] # Download (1-based index) + -o, --output FILENAME # Exact name (single) or prefix (multiple: test_1.png, test_2.png) +``` + +### Steps (To-Do Items) + +Steps are returned in `card show` response. No separate list command. + +```bash +fizzy step show STEP_ID --card NUMBER +fizzy step create --card NUMBER --content "Text" [--completed] +fizzy step update STEP_ID --card NUMBER [--content "Text"] [--completed] [--not_completed] +fizzy step delete STEP_ID --card NUMBER +``` + +### Reactions + +Reactions can be added to cards directly or to comments on cards. + +```bash +# Card reactions (react directly to a card) +fizzy reaction list --card NUMBER +fizzy reaction create --card NUMBER --content "emoji" +fizzy reaction delete REACTION_ID --card NUMBER + +# Comment reactions (react to a specific comment) +fizzy reaction list --card NUMBER --comment COMMENT_ID +fizzy reaction create --card NUMBER --comment COMMENT_ID --content "emoji" +fizzy reaction delete REACTION_ID --card NUMBER --comment COMMENT_ID +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--card` | Yes | Card number (always required) | +| `--comment` | No | Comment ID (omit for card reactions) | +| `--content` | Yes (create) | Emoji or text, max 16 characters | + +### Tags + +Tags are created automatically when using `card tag`. List shows all existing tags. + +```bash +fizzy tag list [--page N] [--all] +``` + +### Users + +```bash +fizzy user list [--page N] [--all] +fizzy user show USER_ID +fizzy user update USER_ID --name "Name" # Update user name (requires admin/owner) +fizzy user update USER_ID --avatar /path.jpg # Update user avatar +fizzy user deactivate USER_ID # Deactivate user (requires admin/owner) +``` + +### Pins + +```bash +fizzy pin list # List your pinned cards (up to 100) +``` + +### Notifications + +```bash +fizzy notification list [--page N] [--all] +fizzy notification tray # Unread notifications (up to 100) +fizzy notification tray --include-read # Include read notifications +fizzy notification read NOTIFICATION_ID +fizzy notification read-all +fizzy notification unread NOTIFICATION_ID +``` + +### File Uploads + +```bash +fizzy upload file PATH +# Returns: { "signed_id": "...", "attachable_sgid": "..." } +``` + +| ID | Use For | +|---|---| +| `signed_id` | Card header/background images (`--image` flag) | +| `attachable_sgid` | Inline images in rich text (descriptions, comments) | + +--- + +## Common Workflows + +### Create Card with Steps + +```bash +# Create the card +CARD=$(fizzy card create --board BOARD_ID --title "New Feature" \ + --description "

Feature description

" | jq -r '.data.number') + +# Add steps +fizzy step create --card $CARD --content "Design the feature" +fizzy step create --card $CARD --content "Implement backend" +fizzy step create --card $CARD --content "Write tests" +``` + +### Link Code to Card + +```bash +# Add a comment linking the commit +fizzy comment create --card 42 --body "

Commit $(git rev-parse --short HEAD): $(git log -1 --format=%s)

" + +# Close the card when done +fizzy card close 42 +``` + +### Create Card with Inline Image + +```bash +# Upload image +SGID=$(fizzy upload file screenshot.png | jq -r '.data.attachable_sgid') + +# Create description file with embedded image +cat > desc.html << EOF +

See the screenshot below:

+ +EOF + +# Create card +fizzy card create --board BOARD_ID --title "Bug Report" --description_file desc.html +``` + +### Create Card with Background Image (only when explicitly requested) + +```bash +# Validate file is an image +MIME=$(file --mime-type -b /path/to/image.png) +if [[ ! "$MIME" =~ ^image/ ]]; then + echo "Error: Not a valid image (detected: $MIME)" + exit 1 +fi + +# Upload and get signed_id +SIGNED_ID=$(fizzy upload file /path/to/header.png | jq -r '.data.signed_id') + +# Create card with background +fizzy card create --board BOARD_ID --title "Card" --image "$SIGNED_ID" +``` + +### Move Card Through Workflow + +```bash +# Move to a column +fizzy card column 579 --column maybe + +# Assign to yourself +fizzy card self-assign 579 + +# Or assign to another user +fizzy card assign 579 --user USER_ID + +# Mark as golden (important) +fizzy card golden 579 + +# When done, close it +fizzy card close 579 +``` + +### Move Card to Different Board + +```bash +# Move card to another board +fizzy card move 579 --to TARGET_BOARD_ID +``` + +### Search and Filter Cards + +```bash +# Quick search +fizzy search "bug" | jq '[.data[] | {number, title}]' + +# Search with filters +fizzy search "login" --board BOARD_ID --sort newest + +# Find recently created cards +fizzy card list --created today --sort newest + +# Find cards closed this week +fizzy card list --indexed-by closed --closed thisweek + +# Find unassigned cards +fizzy card list --unassigned --board BOARD_ID +``` + +### React to a Card + +```bash +# Add reaction directly to a card +fizzy reaction create --card 579 --content "👍" + +# List reactions on a card +fizzy reaction list --card 579 | jq '[.data[] | {id, content, reacter: .reacter.name}]' +``` + +### Add Comment with Reaction + +```bash +# Add comment +COMMENT=$(fizzy comment create --card 579 --body "

Looks good!

" | jq -r '.data.id') + +# Add reaction to the comment +fizzy reaction create --card 579 --comment $COMMENT --content "👍" +``` + +--- + +## Resource Schemas + +Complete field reference for all resources. Use these exact field paths in jq queries. + +### Card Schema + +**IMPORTANT:** `card list` and `card show` return different fields. `steps` only in `card show`. + +| Field | Type | Description | +|-------|------|-------------| +| `number` | integer | **Use this for CLI commands** | +| `id` | string | Internal ID (in responses only) | +| `title` | string | Card title | +| `description` | string | Plain text content (**NOT an object**) | +| `description_html` | string | HTML version with attachments | +| `status` | string | Usually "published" for active cards | +| `closed` | boolean | true = card is closed | +| `golden` | boolean | true = starred/important | +| `image_url` | string/null | Header/background image URL | +| `has_attachments` | boolean | true = card has file attachments | +| `has_more_assignees` | boolean | More assignees than shown | +| `created_at` | timestamp | ISO 8601 | +| `last_active_at` | timestamp | ISO 8601 | +| `url` | string | Web URL | +| `comments_url` | string | Comments endpoint URL | +| `board` | object | Nested Board (see below) | +| `creator` | object | Nested User (see below) | +| `assignees` | array | Array of User objects | +| `tags` | array | Array of Tag objects | +| `steps` | array | **Only in `card show`**, not in list | + +### Board Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Board ID (use for CLI commands) | +| `name` | string | Board name | +| `all_access` | boolean | All users have access | +| `created_at` | timestamp | ISO 8601 | +| `url` | string | Web URL | +| `creator` | object | Nested User | + +### User Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | User ID (use for CLI commands) | +| `name` | string | Display name | +| `email_address` | string | Email | +| `role` | string | "owner", "admin", or "member" | +| `active` | boolean | Account is active | +| `created_at` | timestamp | ISO 8601 | +| `url` | string | Web URL | + +### Comment Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Comment ID (use for CLI commands) | +| `body` | object | **Nested object with html and plain_text** | +| `body.html` | string | HTML content | +| `body.plain_text` | string | Plain text content | +| `created_at` | timestamp | ISO 8601 | +| `updated_at` | timestamp | ISO 8601 | +| `url` | string | Web URL | +| `reactions_url` | string | Reactions endpoint URL | +| `creator` | object | Nested User | +| `card` | object | Nested {id, url} | + +### Step Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Step ID (use for CLI commands) | +| `content` | string | Step text | +| `completed` | boolean | Completion status | + +### Column Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Column ID or pseudo ID ("not-now", "maybe", "done") | +| `name` | string | Display name | +| `kind` | string | "not_now", "triage", "closed", or custom | +| `pseudo` | boolean | true = built-in column | + +### Tag Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Tag ID | +| `title` | string | Tag name | +| `created_at` | timestamp | ISO 8601 | +| `url` | string | Web URL | + +### Reaction Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Reaction ID (use for CLI commands) | +| `content` | string | Emoji | +| `url` | string | Web URL | +| `reacter` | object | Nested User | + +### Identity Schema (from `identity show`) + +| Field | Type | Description | +|-------|------|-------------| +| `accounts` | array | Array of Account objects | +| `accounts[].id` | string | Account ID | +| `accounts[].name` | string | Account name | +| `accounts[].slug` | string | Account slug (use with --account) | +| `accounts[].user` | object | Your User in this account | + +### Key Schema Differences + +| Resource | Text Field | HTML Field | +|----------|------------|------------| +| Card | `.description` (string) | `.description_html` (string) | +| Comment | `.body.plain_text` (nested) | `.body.html` (nested) | + +--- + +## Rich Text Formatting + +Card descriptions and comments support HTML. For multiple paragraphs with spacing: + +```html +

First paragraph.

+


+

Second paragraph with spacing above.

+``` + +**Note:** Each `attachable_sgid` can only be used once. Upload the file again for multiple uses. + +## Default Behaviors + +- **Card images:** Use inline (via `attachable_sgid` in description) by default. Only use background/header (`signed_id` with `--image`) when user explicitly says "background" or "header". +- **Comment images:** Always inline. Comments do not support background images. + +--- + +## Error Handling + +**Error response format:** +```json +{ + "ok": false, + "error": "Not Found", + "code": "not_found", + "hint": "optional context" +} +``` + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Usage / invalid arguments | +| 2 | Not found | +| 3 | Authentication failure | +| 4 | Permission denied | +| 5 | Rate limited | +| 6 | Network error | +| 7 | API / server error | +| 8 | Ambiguous match | + +**Authentication errors (exit 3):** +```bash +fizzy auth status # Check auth +fizzy auth login TOKEN # Re-authenticate +fizzy setup # Full interactive setup +``` + +**Not found errors (exit 2):** Verify the card number or resource ID is correct. Cards use NUMBER, not ID. + +**Permission denied (exit 4):** Some operations (user update, user deactivate) require admin/owner role. + +**Network errors (exit 6):** Check API URL configuration: +```bash +fizzy auth status # Shows configured account +cat ~/.config/fizzy/config.yaml # Check api_url setting +``` + +## Learn More + +- API documentation: https://github.com/basecamp/fizzy/blob/main/docs/API.md +- CLI repository: https://github.com/basecamp/fizzy-cli diff --git a/internal/skills/embed.go b/internal/skills/embed.go new file mode 100644 index 0000000..a33e839 --- /dev/null +++ b/internal/skills/embed.go @@ -0,0 +1,7 @@ +// Package skills embeds the Fizzy SKILL.md for offline access. +package skills + +import _ "embed" + +//go:embed SKILL.md +var Content []byte diff --git a/internal/skills/embed_test.go b/internal/skills/embed_test.go new file mode 100644 index 0000000..e0cb9d5 --- /dev/null +++ b/internal/skills/embed_test.go @@ -0,0 +1,28 @@ +package skills + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestSkillContentNonEmpty(t *testing.T) { + if len(Content) == 0 { + t.Fatal("embedded SKILL.md is empty") + } +} + +func TestSkillContentFresh(t *testing.T) { + _, thisFile, _, _ := runtime.Caller(0) + canonicalPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "skills", "fizzy", "SKILL.md") + + canonical, err := os.ReadFile(canonicalPath) + if err != nil { + t.Fatalf("Failed to read canonical SKILL.md at %s: %v", canonicalPath, err) + } + + if string(Content) != string(canonical) { + t.Fatal("internal/skills/SKILL.md is out of sync with skills/fizzy/SKILL.md. Run 'make sync-skill' to update.") + } +} diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index bf4cf25..1971103 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -118,27 +118,35 @@ All commands support: | `--token TOKEN` | API access token | | `--account SLUG` | Account slug (for multi-account users) | | `--api-url URL` | API base URL (default: https://app.fizzy.do) | -| `--json` | JSON envelope output (default) | +| `--json` | JSON envelope output | | `--quiet` | Raw JSON data without envelope | +| `--styled` | Human-readable styled output (tables, colors) | +| `--markdown` | GFM markdown output (for agents) | +| `--agent` | Agent mode (defaults to quiet; combinable with --json/--markdown) | | `--ids-only` | Print one ID per line | | `--count` | Print count of results | +| `--limit N` | Client-side truncation of list results | | `--verbose` | Show request/response details | +Output format defaults to auto-detection: styled for TTY, JSON for pipes/non-TTY. + ## Pagination -List commands use `--page` for pagination. There is NO `--limit` flag. +List commands use `--page` for pagination and `--limit` for client-side truncation. ```bash # Get first page (default) fizzy card list --page 1 -# Get specific number of results using jq -fizzy card list --page 1 | jq '.data[:5]' +# Limit to N results +fizzy card list --limit 5 # Fetch ALL pages at once fizzy card list --all ``` +Note: `--limit` and `--all` cannot be used together. + **IMPORTANT:** The `--all` flag controls pagination only - it fetches all pages of results for your current filter. It does NOT change which cards are included. By default, `card list` returns only open cards. See [Card Statuses](#card-statuses) for how to fetch closed or postponed cards. Commands supporting `--all` and `--page`: @@ -954,11 +962,11 @@ fizzy auth login TOKEN # Re-authenticate fizzy setup # Full interactive setup ``` -**Not found errors (exit 5):** Verify the card number or resource ID is correct. Cards use NUMBER, not ID. +**Not found errors (exit 2):** Verify the card number or resource ID is correct. Cards use NUMBER, not ID. **Permission denied (exit 4):** Some operations (user update, user deactivate) require admin/owner role. -**Network errors (exit 7):** Check API URL configuration: +**Network errors (exit 6):** Check API URL configuration: ```bash fizzy auth status # Shows configured account cat ~/.config/fizzy/config.yaml # Check api_url setting