pfix is an unofficial, public, open-source command-line client for the Planfix REST API, written in Go. It ships as a single self-contained binary. It is an independent project — not affiliated with, endorsed, sponsored, or funded by Planfix.
Milestones 1–22 are implemented and merged to main:
-
M1: the config/profile layer, the Planfix transport client,
auth(login/status/logout —loginresolves its target profile before prompting and confirms before overwriting an existing one, with--forceto skip the prompt;--profile <name>saves a second account as a distinct profile), and the rawapipassthrough. -
M2: the typed
taskcommand group (list,view,create,update,comment list,comment add) and theinternal/outputrendering layer (table/detail/raw-JSON) that makes--json/--fields/--quietmeaningful. -
M3: the typed
projectcommand group (list,view,create,update— projects have no comments), plus extraction of the shared command helpers intocmdutil(FieldsCSV/ValidateID/DecodeJSON/ClientFunc) andoutput(ColumnsFor) so every resource reuses them. -
M4: the typed
contactcommand group (list,view,create,update) for people and companies.contact createrequires--template(Planfix rejects a templateless contact). -
M5: the typed
usercommand group (list,view— read-only; the API disables user create and update is sensitive). Resolves theowner/assigneeuser:Nreferences on tasks/projects. -
M6: the typed
reportcommand group (list,view— read-only).viewdecodes the single-report response defensively: Planfix returns it under the misspelled keyrepost, with areportfallback. -
M7: the
configcommand group (list,use,show) for managing profiles locally (no API), pluscmdutil.MaskTokenshared withauth status. -
M8: the typed
datatagcommand group (list,view— read-only). Envelope keys are camelCase (dataTags/dataTag). -
M9: input-validation hardening —
cmdutil.ValidateIDnow rejects non-positive ids across every resource. -
M10: the typed
templatecommand (list <type>— read-only). A new GET-based shape:GET /<type>/templateswith an object-type path segment and no pagination; adds sharedcmdutil.ValidateObjectType. -
M11: the typed
customfieldcommand (list <type>— read-only).GET /customfield/<type>(fixed prefix + type segment), envelopecustomfields; columns ID/NAME/TYPE. -
M12: the typed
objectcommand group (list,view— read-only). POST-list + GET-view with pagination; envelopesobjects/object; object status is fat so STATUS usesstatus.name. -
M13: a
--filter <json>pass-through on the 7 POST-list commands (task/project/contact/user/report/datatag/object) viacmdutil.ApplyFilter— forwards a raw Planfixfiltersarray in the request body. GET-basedtemplate/customfieldare excluded. -
M14: typed field flags on
task create/task update—--template(create-only),--project,--parent,--status(now also on create),--priority(client-validated: the API silently resets invalid values toNotUrgent),--counterparty(contact id orcontact:N),--assignees/--auditors/--participants(comma-separateduser:N/contact:N/group:N; update replaces the list),--start-date/--end-date(ISO input → Planfixdd-MM-yyyy/HH:mm, interpreted in the account timezone). Shared parserscmdutil.ParsePeople/cmdutil.ParseTimePoint; task-localtaskFieldsregisters/applies the flag set for both commands. -
M15: the
pingcommand (GET /ping) — a connectivity + token-validity check that printsOK(--jsonpasses the raw{"result":"success"}through;-qprints nothing and just sets the exit code).auth statusnow validates the token viaGET /pinginstead ofPOST /task/list— lighter and scope-independent (a task-list probe would misreport a valid token scoped only to, say, contacts). Adds sharedcmdutil.DescribeAPIError, which maps the Planfix auth app-codes to actionable hints (code 1 unknown token →pfix auth login; code 5 scope denied → the token lacks the scope), used by bothpingandauth status. -
M16: saved task filters — the read-only
task filterscommand (POST /task/filters; envelopefilters; columns ID/NAME/OWNER viaowner.name) and a--saved-filter <id>flag ontask listthat forwards afilterIdin the request body. The id is an opaque string (system tokens:all/:in/:out/:auditor a numeric id) forwarded verbatim; an unknown id surfaces the API'scode 41error. This is distinct from the declined typed filter flags — it applies an existing named filter rather than building afiltersarray. When both are supplied,--saved-filterand--filtercombine as a logical AND — the raw filter further narrows the saved view (both constraints apply; verified live against the API). -
M17: the global
--jq <expr>flag — filters JSON output through an embedded jq engine (github.com/itchyny/gojq, no externaljqbinary needed). Setting--jqimplies--json, and the expression is compiled and validated up front (GlobalOpts.PreRun), so an invalid expression fails before any API call. Applied at the single JSON choke point,output.EmitJSON, which every command now calls instead ofoutput.JSONdirectly — so it works wherever JSON is emitted: all typed commands,ping, andapi. -
M18: task status discovery —
task statuses [task-id | --process id](resolves a task'sprocessIdviaGET /task/{id}?fields=processId, thenGET /process/task/{id}/statuses; envelopestatuses; columns ID/NAME/ACTIVE; a note on stderr when the set is empty, e.g. an unknown process). Addstask processesandcontact processes(GET /process/{type}; envelopeprocesses; columns ID/NAME) sharing a newinternal/cmd/processespackage. Also allows--status 0— the valid Draft status — ontask create/update, which the shared positive-id check previously rejected. Notes from live recon:GET /process/tasklists custom processes only (the built-in process is hidden, but reachable via a task'sprocessId); the object status endpoint (GET /object/{id}/statuses) is unused (dead on process-based accounts); a task's process is fixed at creation (not writable); Draft is create-only (the API rejects moving an existing task to Draft). -
M19: task custom-field values — a repeatable
--cf <id>=<value>ontask create/task updateand rendered custom-field rows ontask view. Values are typed from the field definition: pfix does oneGET /customfield/task?fields=id,name,type,enumValuesto map each id to its type code (and, for a list, its option labels), then formats short/multiline text as a string, number as a JSON number, and list (type 8) as the bare option label string (unsupported type codes error, pointing topfix api). A list has no option ids over REST — the definition exposes options only asenumValuesstrings — and the API validates nothing, storing any unrecognized value verbatim as a phantom, so pfix rejects a label outside the set before the write (the error lists the valid options, each quoted, since a label may contain spaces or commas). Labels match exactly: no trimming, no case folding. Unknown ids and type mismatches also fail before the write; the field must be bound to the task's template/process or the API drops the value silently (documented, not detected).task viewrenderscustomFieldDataasfield.name = stringValuewhen the numeric ids are requested via--fields, dropping the numeric-id columns from the table. Sharedcmdutil.ParseCustomFieldSpecs/BuildCustomFieldData;output.Detailgained trailingextra ...KVrows. -
M20: lookup endpoints —
user groups,user positions,contact groups(sharedinternal/cmd/groupspackage,GET /<type>/groups; envelopegroups),customfield types(GET /customfield/type; envelopecustomFieldTypes), and a TYPE-name decode incustomfield listvia a new optionaloutput.Column.Formathook + an embeddedtypeNamescatalog. All read-only, GET-based; onlyuser/groupsresolves the assigneegroup:Nrefs (recon:contact/groupsare the segments a contact base is divided into — the vendor's English docs call these "contact groups", so pfix does too, rather than "categories" from the localized UI — andproject/groups/directory/groupscome back empty). -
M21:
--fieldsdiscovery in help — every read command (list/viewfor task/project/contact/user/report/datatag/object; extended to the rest in M23) now lists its default and selectable fields in--help. Sourced once from the API's OpenAPI description (swagger.json, not committed) and baked into co-locatedxxxAvailableFieldsconstants;taskhelp also notes numeric custom-field ids are selectable. Sharedcmdutil.FieldsHelpformats the block into each command's CobraLong. Help text only —--fieldsbehavior is unchanged, and the API silently ignores unknown fields, so the list is advisory. Note from recon: the API accepts unknown field names without error (result: success, field dropped), solistfield sets that swagger leaves un-enumerated (project/user/report/datatag list) reuse the siblingviewvocabulary, andobjectuses theObjectResponseschema. -
M22: the
filecommand group (view,download— read-only) and a sharedfileslist subcommand ontask/contact/project(GET /<type>/{id}/files; envelopefiles; columns ID/NAME/SIZE).--source attached(default) mirrors the endpoint;--source inlinescrapesuniqueid=(\d+)file ids out of description/comment HTML for editor-uploaded images, which the attachment API never returns under any flag, then resolves each id withGET /file/{id}and composes afiles[]response (nosourcekey — same shape as the endpoint, so--jsonlooks identical either way).--description-only(task/contact only; passes the endpoint's ownonlyFromDescription) and--limit/--offset(project only; the endpoint's own paging) are each valid only with--source attached— combining either with--source inlineerrors.file view <id>rendersID/NAME/SIZE, plusLINKwhen the file carries one.file download <id>streams bytes byte-exact via a newplanfix.Client.Stream;-o <path>/-o <dir>//-o -resolve the destination (auto-naming via a metadata lookup when-ois omitted or names a directory), refuses to overwrite an existing file without--force, and rejects--json/--jq(it writes raw bytes, not JSON). Streaming removes the whole-request timeout that bounded every prior command, somain.gonow wires asignal.NotifyContext—Ctrl-Ccancels a stalled download instead of hanging forever. Also fixespfix api, which previously appended a spurious newline to any non-JSON (i.e. binary) response body; it now branches on the responseContent-Type, writing anything other thanapplication/jsonverbatim, and names theContent-Typein the error when--jqis pointed at a non-JSON response instead of claiming it isn't valid JSON. Recon: the vendor-documentedsizefield isceil(bytes / 1024), not bytes — never use it for an exact count, onlyContent-Length; an object'sfilesfield (task view --fields files, etc.) is description-scoped, matchingonlyFromDescription=true, not every file attached to the object; inline editor uploads appear in nofiles[]array anywhere, regardless of that flag; anddescriptionis HTML on task and project but plaintext on contact, so a contact's inline scrape reads its comment feed instead of its description field — reading the field directly would silently find nothing. -
M23: the
commentcommand group (view,edit,delete) plus the flags that finish the task-scoped pair. A Planfix comment is one global entity with an optional parent link (taskorcontact) —CommentCreateRequest/CommentUpdateRequest/CommentResponseare shared by both scopes and ids come from one sequence — so onlylist/addare genuinely parent-scoped and stay undertask, whileview/edit/deletetake a bare comment id and work on task- and contact-owned comments alike.comment viewrenders ID/CREATED/AUTHOR/TASK-or-CONTACT/PINNED/HIDDEN/TEXT, dropping whichever parent row the comment lacks.comment editsends only the flags the user set (the API applies partial updates), and resolves the comment's real parent viaGET /comment/{id}?fields=task,contactbefore posting: the update endpoint's own parent segment is ignored by the API (verified — a task comment edits fine throughcontact/3/...and through a task id that does not exist), and pfix must not depend on that. The parent-scoped endpoint is also the only write path —/comment/{id}answers405 Method Not Allowedto bothPOSTandPUT, advertisingAllow: DELETE,GET,OPTIONS(an HTML error page, no JSON envelope, so it parses ascode 0with an empty message) — so the resolvingGETis not avoidable by posting to the entity endpoint instead.comment deleterequires--force, deletes softly (the comment then 400s on view/edit but still lists undertask comment list --include-deleted, with no way back), and maps the API's opaquecode 0to the one known cause: a task's first comment holds its description and cannot be deleted. Also--include-deletedontask comment list(typeList: "Deleted", which returns deleted comments, live comments, and system/audit entries — e.g. the record written on a task rename — that the default listing hides;typedoes not separate them: a rename audit record and a task's description comment both reporttype: "None"),--pinned/--hiddenontask comment add, and--silenton add/edit. Recon: editing a task's first comment does not changetask.description— the description seeds comment #1 at creation and the two diverge afterwards;typeListAllbehaves likeComments(deleted stay hidden); the standalonePOST /comment/listis cross-entity but requires afiltersarray, failing withcode 0without one. M23 also completed M21's--fieldshelp sweep:task comment listandcomment viewshare one exportedcomment.AvailableFields(both read the same object), and the block now also coverscustomfield list/types,user groups/positions,contact groups,task/contact processes,task statuses, and the threefilescommands. Two carry caveats: thefilesblock lists columns, not requestable fields (the endpoints have no server-side selection), andtemplate listgets a Default-fields line but no block — a template's vocabulary is the listed object type's own, so its help points at that type'sview --helpinstead.groups.Long(short)exists becauseuser/contactoverride the shared command'sShort; rebuildingLongthrough it keeps the block that a raw string assignment would have silently dropped. -
M24: the
task checklistgroup (list,view,add,update) over the four checklist endpoints. Checklists hang off tasks only, so the group is task-scoped liketask comment:POST /task/{id}/checklist/list(envelopeitems;offset/pageSize≤100/fields),GET /task/{id}/checklist/{itemId}(envelopeitem),POST /task/{id}/checklist(create;namerequired, answers{result, id}),POST /task/{id}/checklist/{itemId}(update). There is no delete — verified, not assumed:DELETEanswers405on both the item and the collection route, theirOPTIONS/WADL advertise onlyGET/POST,/checklist/{id}/deleteis a404, anisDeletedkey is refused (code 30, Cannot deserialize), andDELETE /task/{itemId}on the underlying record is405too — so the group's help says so rather than leaving a silent hole. The item vocabulary is enumerated by the API (id,name,isDone,parent,dateTime,assignees), so unusually the--fieldsblock is exact rather than advisory; the server default isidalone, so pfix ships its own (id,name,isDone;viewaddsdateTime,assignees). Items nest:--parent <item-id>onaddnests and onupdatemoves (at least three levels deep), the listing is flat but depth-first with each child following its parent, and the API validates the reference — a parent from another task iscode 6, an unknown onecode 601.updatesends only the flags the user set (thecomment editcontract) and--done=falseunticks; with no flags it refuses instead of posting an empty body. The update endpoint's documented200/202response carries afailuresarray besideresult: "success", so pfix decodes it and turns a non-empty one into an error — otherwise a rejected write could print as a success (under--jsonthe response still passes through, then the error sets the exit code); no live probe has produced a non-empty one, so this is a contract honored rather than an observed behavior.view/updatetake both ids, and the two sides treat the pair differently: a read ignores the task segment (an item resolves through a task id that does not exist — the M23comment edittrap), while a write validates it (Checklist item does not belong to task by id - N,Task not found by id - N). Recon notes: a checklist item is aCheckmarktask record sharing the task id sequence, butGET /task/{itemId}refuses it, so the checklist routes are the only way in; unknown keys in a write body are rejected outright (code 30) rather than ignored the way unknownfieldsnames are; and an assignee reference the API cannot resolve is dropped silently — since the list is replaced wholesale,--assignees user:999999clears the item's people and still answers success, which the flag help warns about because the response cannot betray it. All four commands, both read and write, are verified live against a disposable account.
All tested. Keep this file in sync as code lands — completed work is logged above and in Build order; planned, postponed, and declined work lives in Roadmap below.
Kept separate from the milestone log above so landing a new milestone only appends to Status and Build order — this section changes on its own schedule.
- Next (as API access allows): a typed
directoryresource. - Postponed — not exposed via REST:
processmutation and running processes / workflow actions. (Process listing/status endpoints already back task status discovery; see M18 and M20.) - Declined by the user: deletes (except
comment delete, shipped in M23 at the user's request),user update, typed filter flags, and color.
- Public and vendor-neutral. Describe pfix's behavior on its own terms. Committed artifacts (code, comments, docstrings, identifiers, fixtures, docs, README, commit messages) must not name, reference, or compare against other products or tools, and must not include copied or cited third-party material. (Sole exception: a committed
CLAUDE.mdwhose entire content is the import line@AGENTS.md, so agent tooling that looks for that filename loads this vendor-neutral file instead.AGENTS.mdis the canonical, tool-agnostic source;CLAUDE.mdis only a pointer to it.) - Unofficial. pfix is an independent project with no affiliation to Planfix. Keep the disclaimers in README.md and this file accurate.
- Public dependencies only. Every dependency must be installable from public sources. No private package indexes or internal libraries.
- License: Apache-2.0.
- Go (latest stable) with
github.com/spf13/cobrafor the command tree. - Standard-library
net/httpfor the API client;gopkg.in/yaml.v3for config;golang.org/x/time/ratefor request throttling;golang.org/x/termfor hidden token entry;github.com/itchyny/gojq— embedded jq engine for--jqoutput filtering. - Module path:
github.com/a68366/pfix-cli. Binary:pfix. - Deliberately lean: no config framework (no Viper), no color libraries;
gojqis the one dependency added for a specific feature (--jq), kept direct and minimal (itstimefmt-gotransitive stays// indirect).
Implemented:
main.go— entry point. Wiressignal.NotifyContext(os.Interrupt)and callscmd.Execute(ctx), soCtrl-Ccancels a long-running command (e.g. a stalledfile download) instead of hanging until the process is killed.internal/cmd/— Cobra commands:root(its--versionflag aliasesversion),version,ping(connectivity + token check),auth/(login/status/logout),api/,task/(list,view,create,update,statuses,processes,files, and thecommentandchecklistsub-groups —checklistislist/view/add/update, split acrosschecklist.go(reads) andchecklist_write.go(writes) —create/updatetake a repeatable--cf <id>=<value>to set typed custom-field values, andviewrenders them asfield.name = stringValuerows when the numeric ids are requested via--fields;comment listtakes--include-deleted, andcomment addtakes--pinned/--hidden/--silent),project/(list,view,create,update,files),contact/(list,view,create,update,processes,groups,files),user/(list,view,groups,positions— read-only),report/(list,view— read-only),datatag/(list,view— read-only),template/(list <type>— read-only, GET-based),customfield/(list <type>,types— read-only, GET-based),object/(list,view— read-only),file/(view,download— read-only),comment/(view,edit,delete— a global comment id, not scoped to a parent),config/(list,use,show— local profile management). The data packageinternal/configis imported aspfconfiginsideinternal/cmd/configto avoid the package-name collision.internal/cmd/processes/— the sharedprocesseslist command backing bothtask processesandcontact processes(GET /process/<type>; envelopeprocesses; columns ID/NAME).internal/cmd/groups/— the sharedgroupslist command backing bothuser groupsandcontact groups(GET /<type>/groups; envelopegroups; columns ID/NAME).internal/cmd/files/— the sharedfileslist command backingtask files,contact files, andproject files(files.NewCmd(g, files.Options{Type, Paging, DescriptionOnly})lets each parent register the subset of flags its endpoint supports;GET /<type>/{id}/files; envelopefiles; columns ID/NAME/SIZE).--source inlinescrapes description/comment HTML instead of calling the endpoint, using a per-resource strategy since task/contact keep their HTML in comments while project keeps it on the object itself.internal/cmd/comment/— the top-levelcommentgroup (view,edit,delete) operating on a global comment id.editresolves the comment's parent (task/{id}orcontact/contact:{id}) before posting, since the update endpoint ignores the parent segment it requires.internal/cmdutil/—GlobalOpts(persistent flags), theClient()/ClientFunc()helpers that build a configured client from the active profile, and the resource-agnostic command helpers shared by every typed command (FieldsCSV,ValidateID,DecodeJSON,ApplyFilter,ParsePeople,ParseTimePoint,DescribeAPIError,ParseCustomFieldSpecs,BuildCustomFieldData,FieldsHelp,ScanFileIDs,SafeFileName).internal/planfix/— Planfix REST client. A low-levelClient.Do(ctx, method, path, body, headers)carries auth, throttling, and retries;Client.JSON(ctx, method, path, body)is the typed-command convenience over it (marshals the body, returns raw response bytes, maps status ≥300 to*APIError).Client.Stream(ctx, path)is a GET sibling forfile download: it sharesDo's throttle/retry loop but returns the response withBodyunread and no whole-request timeout (only a response-header timeout), so a large download's body read is never cut off mid-stream.errors.goholdsAPIError(incl. the Planfix appCode)/ParseError.internal/output/— renders decoded JSON:Table/Detailviatext/tabwriter, a dot-pathFlatten(e.g.status.name; an object with nonamefalls back to itsid),ColumnsFor(default vs--fields-derived columns), an optional per-ColumnFormatrender hook (used bycustomfield listto decode the TYPE code to its name; honored byTableonly, notDetail), rune-safeTruncate,JSON(pretty-print/raw passthrough — shared withapi), andjq.go(CompileJQ/EmitJSON).EmitJSONis the flag-aware JSON entry point every command calls now — it runs the compiled--jqquery over the decoded response when one is set, and otherwise falls back toJSONunchanged.internal/config/— profile load/save (atomic, mode 0600) and value precedence (Resolve,ResolveProfileName).internal/buildinfo/— version/commit/date injected at build time.
- Done (M1):
auth+ genericapi— credentials/profiles plus the raw passthrough make every endpoint reachable immediately. - Done (M2):
task— list, view, create, update, and comments + theinternal/outputrendering layer. - Done (M3):
project— list, view, create, update + shared command-helper extraction. - Done (M4):
contact— list, view, create, update (people + companies). - Done (M5):
user— list, view (read-only). - Done (M6):
report— list, view (read-only). - Done (M7):
config— list, use, show (local profile management). - Done (M8):
datatag— list, view (read-only). - Done (M9): input-validation hardening (
ValidateIDrejects non-positive ids). - Done (M10):
template— list per object type (read-only, GET-based). - Done (M11):
customfield— list per object type (read-only, GET-based). - Done (M12):
object— list, view (read-only). - Done (M13):
--filterJSON pass-through on the POST-list commands. - Done (M14): typed field flags on
task create/task update. - Done (M15):
pingcommand +auth statustoken check viaGET /ping(sharedcmdutil.DescribeAPIErrorauth-error hints). - Done (M16): saved task filters —
task filters(read-only) +task list --saved-filter(filterIdpass-through). - Done (M17): global
--jq <expr>output filter (embedded jq engine, implies--json, validated up front). - Done (M18): task status discovery (
task statuses,task processes,contact processes) +--status 0(Draft) fix. - Done (M19): task custom-field values (
--cfon create/update; rendered on view; type-aware viaGET /customfield/task). - Done (M20): lookup endpoints (
user groups,user positions,contact groups,customfield types) + a TYPE-name decode incustomfield listvia the newoutput.Column.Formathook. - Done (M21):
--fieldsdiscovery in--help(sharedcmdutil.FieldsHelp; co-locatedxxxAvailableFieldsconstants on the 14 read commands). - Done (M22): files — list/view/download + inline scrape + binary-output fix.
- Done (M23): comments —
comment view|edit|delete+--include-deleted,--pinned,--hidden,--silent. - Done (M24): task checklists —
task checklist list|view|add|update(the API has no delete).
- Auth: Bearer token + account domain; base URL
https://<domain>/rest/.... - Config file:
~/.config/pfix/config.yml(mode 0600) with multiple named profiles. - Precedence: command-line flags > environment (
PFIX_DOMAIN,PFIX_TOKEN,PFIX_PROFILE,PFIX_CONFIG) > config file. Profile name resolves throughconfig.ResolveProfileName(flag > PFIX_PROFILE > current_profile > "default") — use it everywhere a command needs the active profile, so the commands stay consistent. - Output: typed
taskcommands default to a human-readable table (list) or key/value detail (single object), rendered byinternal/output(stdlibtext/tabwriter, no color).--jsonemits the API response unmodified (pretty-printed);--fieldsoverrides the requested fields and table columns;-q/--quietdrops the header row (lists) or prints only the affected id (create/update/comment add/comment edit/comment delete).--jq <expr>filters the JSON output through a jq expression (implies--json).apialways emits raw JSON. Errors go to stderr with a non-zero exit code. The Planfix layer stays thin — commands render generically from decodedmap[string]anyvia dot-paths rather than typed structs, so unconfirmed nested shapes need no model. - Transport:
Client.Doreturns the HTTP response for any status (callers inspectStatusCodeand useplanfix.ParseErrorfor detail). It retries connection errors + 5xx, never 4xx. Every request carries aUser-Agentofpfix/<version>(frombuildinfo.Version, set on theClient.UserAgentfield inNew); a caller-suppliedUser-Agentheader — e.g.api -H "User-Agent: ..."— overrides it. - Proxy: the client follows the standard Go proxy environment variables (
HTTP(S)_PROXY/NO_PROXY) for every request. Both HTTP paths resolve the proxy through theClient.Proxyfield (defaulthttp.ProxyFromEnvironment, set inNew):Newclones the standard transport for the sharedDo/JSONclient, andStream(file download) builds its own timeout-free transport — both pointProxyatc.Proxy, resolved per request, so overriding the field (or nil-ing it to force a direct connection) governs all requests. A barehttp.Transportwould instead disable proxying outright, which is whyStreammust not use one.ALL_PROXYis not honored (the Go stdlibhttpproxypackage does not read it). - API specifics: list endpoints are POST with
pageSize/offset/fields/filters; fields must be requested explicitly — ship sensible per-resource defaults, overridable with--fields. - Files:
task files/contact files/project files --source inlinecomposes its JSON output ({"result":"success","files":[...]}built from resolved ids) rather than echoing an API response — there is no single endpoint for inline files, so--json/--jqthere reflect pfix's aggregation, not one call's response body. - Files:
--fieldson the files commands (task files,contact files,project files) selects table columns only. No file-listing endpoint supports server-side field selection —/file/{id}'s ownfieldsparameter is accepted and silently ignored — so unlike other typed commands,--fieldsthere never changes what the API is asked for. - Comments: a task's
descriptionis its first comment — it seeds comment #1 at task creation and shows up as such intask comment list/view. The two values diverge from then on: editing that first comment viapfix comment editchanges only the comment, never the task'sdescriptionfield, and there is no re-sync in either direction. - Checklists: task-only, and the item route's two methods disagree about the task segment — the
GETignores it (an item resolves through any task id, including one that does not exist — thecomment edittrap) while the updatePOSTvalidates it and answers400.task checklist view/updatetherefore take both ids and forward what the user gave. The update response also carries afailuresarray while still answering200 success, so every checklist write decodes it and errors on a non-empty list. Items nest throughparent, which the API validates; an unresolvableassigneesreference, by contrast, is dropped silently and clears the list. - Destructive commands (e.g.
comment delete): refuse to run without--forceand never prompt for confirmation, even interactively. This is deliberate — behavior stays identical whether the command runs from a terminal or a script, and there is no TTY-detection branch to keep in sync. Follow this rule for any future destructive command.
- Use the project's Go toolchain (latest stable). The
godirective is pinned ingo.mod; keep the module graph tidy (go mod tidy) — every imported dependency must be in the directrequireblock, not// indirect. - Format:
gofmt -l .(output must be empty). - Vet:
go vet ./.... - Test:
go test ./.... Table-driven tests; stand up a fake API withnet/http/httptest; mock only at the HTTP boundary, never the code under test. - Lint (required — CI gates on it):
golangci-lint run ./...must report0 issuesbefore a milestone is considered done or a release is tagged.gofmt/go vetare not a substitute (errcheckflags unchecked error returns, including in tests). Config in.golangci.yml. - Build:
go build -o pfix .; release builds embed metadata via-ldflags "-X github.com/a68366/pfix-cli/internal/buildinfo.Version=..."(andCommit/Date). - CI: GitHub Actions —
.github/workflows/ci.ymlruns gofmt/vet/build/go test -race/tidy-check plus golangci-lint (config in.golangci.yml) on pushes tomainand PRs;.github/workflows/release.yml+.goreleaser.ymlpublish multi-platform binaries to GitHub Releases onv*tags via GoReleaser. - Releasing: on a green
main, create and push an annotated tag —git tag -a vX.Y.Z -m "pfix vX.Y.Z" && git push origin vX.Y.Z. The tag triggers the release workflow: GoReleaser builds linux/darwin/windows × amd64/arm64 archives (version/commit/date embedded via ldflags), writeschecksums.txt, assembles the changelog from Conventional Commit subjects (docs/test/chore/citypes are excluded — pick commit types with the changelog in mind), and publishes the GitHub Release immediately (draft: falsein.goreleaser.yml). Verify the Actions run and the Releases page;go install github.com/a68366/pfix-cli@latestresolves the new tag once the Go module proxy refreshes.
- All behaviour changes must be covered by tests — if it isn't tested, it isn't done.
- Test decisions and branches (error mapping, config precedence, retry behavior, request building), not glue code.