# Build
go build -o op .
# Run tests
go test ./...
# Run a specific test package
go test ./components/printer/...
# Install locally
go install .The codebase is organized in strict layers. Each layer only depends on layers below it.
cmd/ # Cobra commands — CLI entry points only, no business logic
components/
resources/ # Business logic — API calls, option handling
paths/ # API path constants (paths.go — single file)
requests/ # HTTP client (GET, POST, PATCH)
parser/ # JSON response parsing
printer/ # Terminal output formatting
routes/ # Browser URL generation
common/ # Shared utilities (string, slice, math)
configuration/ # Multi-profile config (INI), CLI version
launch/ # Browser launcher
models/ # Domain models (plain structs, no logic)
dtos/ # JSON DTOs with Convert() to models
API response → parser.Parse[SomethingDto]() → dto.Convert() → models.Something → printer.Something()
- DTOs live in
dtos/, named<Resource>Dto - DTOs mirror the OpenProject API v3 HAL JSON structure
- Links use
*LinkDtowithHrefandTitlefields, serialized as_links - Every DTO implements
Convert() *models.Somethingto produce a domain model - Collections follow the pattern:
<Resource>CollectionDtowithEmbedded.<Resource>Elements omitemptyon all JSON fields in DTOs used for POST/PATCH bodiesWorkPackageDtoincludes adisplayIdfield (camelCase, from the API) mapped toDisplayIdon the model; always present — holds the semantic identifier (e.g.PROJ-123) when project-based identifiers are enabled, or the numeric id as a string otherwise
- Commands follow
op <noun> <verb>(noun-first), e.g.op work-package list,op time-entry create - Each noun has its own package under
cmd/(workpackage,timeentry,project,user, …) — package names use no hyphens even when the command name does (e.g.work-package→package workpackage) - Each noun package exposes a
RootCmdregistered incmd/root.go - One file per verb within each noun package (e.g.
cmd/workpackage/list.go,cmd/workpackage/create.go) - Flags: always define long flag names; add short flags (
-p,-o) for frequently used ones - Flags that resolve a resource (e.g.
--type,--assignee) perform an API lookup and store the resolved link in the DTO
- Each resource has its own package under
components/resources/ - Operation types use
iotaenums:CreateOption,UpdateOption,FilterOption - Operations are dispatched via a
map[Option]func(...)— add new options by extending the map - Public API:
Create(...),Lookup(id),All(filters, query, ...),Update(id, options)
- Config stored as INI at
~/.config/openproject/config(or$XDG_CONFIG_HOME/openproject/config) - Each profile is an INI section:
[name]withhostandtokenkeys - Profile names: letters, digits,
-,_only; no leading/trailing hyphens; validated byValidateProfileName, sanitized bySanitizeProfileName - Key constants:
DefaultProfile = "default",EnvProfile = "OP_CLI_PROFILE" - Key functions:
ReadConfig(profile),WriteConfigForProfile(profile, host, token),DeleteProfile(profile),AllProfiles() OP_CLI_HOST/OP_CLI_TOKENenv vars override all profiles;OP_CLI_PROFILEselects a profile (overridden by--profileflag)- Old single-line format (
host token) is auto-migrated to[default]on first read
- All API paths are defined in
components/paths/paths.go - Functions are named after the resource:
WorkPackage(id),WorkPackages(),ProjectWorkPackages(projectId) - All paths are relative (no host), starting with
/api/v3 - Project path functions (
Project,ProjectWorkPackages,ProjectVersions,ProjectBudgets) take astringthat may be either a numeric ID ("42") or a human-readable identifier ("my-project"); the OpenProject API accepts both forms at the same endpoints WorkPackage(id)andWorkPackageActivities(id)take astringthat may be either a numeric ID ("12345") or a project-based semantic identifier ("PROJ-123"); validated bywork_packages.ValidateIdentifier
- All terminal output goes through
printer/— neverfmt.Printlndirectly in commands - Color scheme: Red = ID, Green = type, Cyan = subject/name, Yellow = status
printer.Error(err)for errors,printer.ErrorText(msg)for plain error stringsprinter.Info(msg)for progress messages,printer.Done()after successful mutations- Work packages display
DisplayIdfrom the API: semantic form (e.g.PROJ-123) when project-based identifiers are enabled,#Nfor numeric-only systems (wheredisplayIdequals the numeric id) - Work package browser URLs use the short
wp/<displayId>form (e.g.wp/PROJ-123orwp/42)
- Component tests use external test packages (
package printer_test,package requests_test,package work_packages_test); cmd-layer tests use internal packages (package workpackage) because they exercise unexported command handlers and flag variables TestMaininprinter_testinitializes shared state (routes, printer) for the package- Tests use plain
t.Errorf— no test framework, no assertions library - Tests exist for
printer,requests,common,configuration,components/resources/work_packages, and thecmd/packages (root,activity,project,user,workpackage) - Regression tests for command/resource behaviour use
httptestlocalhost servers and count mutating requests to assert no-mutation guarantees - When adding a new printer function, add a corresponding test in
components/printer/ - When adding a new configuration function, add a corresponding test in
components/configuration/
| Package | Purpose |
|---|---|
github.com/spf13/cobra |
CLI framework |
github.com/fatih/color |
Terminal colors (via printer) |
github.com/briandowns/spinner |
Progress spinner |
github.com/go-git/go-git/v5 |
Git integration (op git commands) |
github.com/sosodev/duration |
ISO 8601 duration parsing |