Noun-first commands, multi-profile support, semantic WP identifiers, and new commands#15
Noun-first commands, multi-profile support, semantic WP identifiers, and new commands#15cbliard wants to merge 27 commits into
Conversation
New noun-first commands (old verb-first commands kept during transition): op workpackage list/create/update/inspect (was: op list/create/update/inspect workpackage[s]) op activities list [--wp id] (was: op list activities [id]) op project list/inspect (was: op list/inspect project[s]) op user search (was: op search user) op timeentry list (was: op list timeentries) op type list (was: op list types) op status list (was: op list status) op notification list (was: op list notifications) op activities list uses --wp flag instead of positional argument to allow future scoping (--project, global) without breaking the interface. op git start workpackage is unchanged.
Deleted: cmd/list/, cmd/create/, cmd/update/, cmd/inspect/, cmd/search/ Remaining commands: op workpackage list/create/update/inspect op activities list [--wp id] op project list/inspect op user search op timeentry list op type list op status list op notification list op git start workpackage (unchanged) op login (unchanged)
- --wp renamed to --work-package for consistency with other flag names - MarkFlagRequired ensures Cobra validates the flag before execution rather than failing at runtime
Introduce a Renderer interface with TextRenderer (existing behavior) and JsonRenderer (new) implementations. The active renderer is selected at startup via the root command's PersistentPreRun based on the --format flag value.
The predicate (Id == target) is not monotone — sort.Search requires >=. Also, len(users)-1 as the upper bound excluded the last user from the search. Together, these bugs caused the wrong user name to be resolved when the target user appeared before the last position in the slice.
Cobra supports hyphens in command names; aligning with common CLI conventions (docker, kubectl, gh). Package names remain unhyphenated as Go requires. Also updates README and CLAUDE.md to reflect noun-first syntax throughout.
go build -o op produces a local binary that should not be versioned.
Lists budgets for a given project via GET /api/v3/projects/{id}/budgets.
Fetches a single budget by ID via GET /api/v3/budgets/{id}.
These flags are irrelevant for shell completion generation.
Assignee was already supported for update and display; create was missing it. Takes a user ID, consistent with the update command.
Supports multi-line markdown descriptions. Format is hardcoded to markdown as textile is no longer supported in OpenProject.
Shows configured server URL and current user info. Handles missing config (not logged in) and 401 (invalid token) gracefully.
Supports --work-package (required), --hours (required), --activity (optional, looked up by name), --spent-on (default: today), --user, --comment flags. Also fixes nil pointer dereferences in TimeEntryDto.Convert() for optional linked resources (project, work package, user, activity, comment).
When GET /api/v3/time_entries/activities returns an error (endpoint not available in all OpenProject versions), show a clear message instead of the raw API error. When the endpoint works but the name doesn't match, list available activities.
Previously the CLI stored a single host+token pair in a flat config file,
making it awkward to switch between instances (community, self-hosted, staging,
etc.). Users had to re-run `op login` each time, overwriting their credentials.
This commit introduces named profiles so multiple instances can be configured
and used side-by-side.
Config format
The config file (~/.config/openproject/config, or $XDG_CONFIG_HOME) is now
stored as INI with one section per profile:
[default]
host = https://community.openproject.org
token = abc123
[work]
host = https://work.example.com
token = xyz789
Existing single-line files ("host token") are silently migrated to [default]
on the first read.
Profile names
Only letters, digits, - and _ are allowed, with no leading/trailing hyphens.
The interactive prompt sanitizes invalid input and re-prompts with the
corrected name as the new default.
op login
- Prompts "Profile name? [default]" before asking for credentials.
- --profile <name> skips the prompt and uses that name directly (validated
upfront; errors immediately if the name is invalid).
- OP_CLI_PROFILE env var behaves the same as --profile for login.
- If the profile already exists the user is asked to confirm overwrite.
op logout (new command)
- Removes a profile from the config file.
- Defaults to "default"; use --profile to target another.
- Idempotent: no error if the profile does not exist.
- Always asks for confirmation before deleting.
op whoami
- Without --profile: shows every configured profile (server + user), each
block separated by a blank line.
- With --profile <name>: shows only that profile.
- Output now includes a "Profile:" header line per entry.
All other commands
- Accept a global --profile flag (persistent, default "default").
- OP_CLI_PROFILE env var sets the default profile; --profile overrides it.
- OP_CLI_HOST / OP_CLI_TOKEN still override everything (highest priority).
- If an explicitly named profile does not exist, an error is shown and
nothing is done.
Hiding flags via InheritedFlags().MarkHidden() mutates the shared *Flag structs, accidentally hiding --format and --verbose from all sub-commands. Removing the loop lets them appear correctly under "Global Flags" everywhere.
Allows listing the direct children of a work package by passing its ID: op work-package list --parent-id 42 The flag translates to a parent filter on the OpenProject API v3 (operator "="), which is ANDed with any other active filters such as --project-id, --status, or --type. A pre-flight lookup verifies that the given work package exists before issuing the filter query. If it does not, the command exits with a clear error naming the flag: --parent-id: work package #42 not found. Only direct children are returned (depth 1). Listing a full subtree would require multiple API calls and is out of scope.
All commands that reference a project — op project inspect, op work-package list, op work-package create, and op budget list — now accept both the numeric project ID (e.g. 42) and the human-readable project identifier found in the URL (e.g. "foobar" from /projects/foobar/work_packages). Changes: - --project-id flag on op work-package list renamed to --project; the old name is kept as a hidden alias for backward compatibility - op project inspect updated to accept [id|identifier] as its argument; the previous hard rejection of non-numeric input is removed - op budget list --project flag updated to accept string values - op work-package create --project flag updated to accept string values - Project identifier field added to models, DTOs, and JSON output so it is visible when listing or inspecting projects - Project display format updated to "#14 MyProject (my-project)" so users can discover the identifier to reuse in flags - routes.ProjectUrl now builds the human-readable URL (projects/my-project) instead of the numeric one (projects/14) - Input validated client-side: only alphanumeric characters, -, _, and + are accepted; invalid input is rejected before any API call with a clear error message pointing to the offending flag - A friendly 404 error is shown when the project is not found instead of the raw API error response
OpenProject community now supports Jira-style identifiers introduced in https://community.openproject.org/work_packages/72427. All commands that accept a work package ID (inspect, update, list --parent-id, activities --work-package, git start workpackage) now accept both numeric IDs for backward compatibility and project-based identifiers (PROJECTID-NUMBER). Validation enforces the identifier rules: uppercase letters, digits, or underscores; max 10 characters; must start with a letter; followed by a hyphen and a numeric sequence number.
…ayId field The OpenProject API returns a displayId field alongside the numeric id. When project-based identifiers are enabled, this is the semantic key (e.g. SJF-13); on instances without the feature it matches the numeric id and the display falls back to the conventional #N format. Wire displayId through DTO → model → printer (text and JSON renderers). Column alignment in list output handles mixed numeric/#N and semantic IDs.
There was a problem hiding this comment.
Pull request overview
This PR is a broad refactor and feature expansion of the OpenProject CLI, primarily restructuring commands into a noun-first hierarchy while adding multi-profile configuration, semantic work package identifiers, JSON output, and several new resource commands (time entries, budgets, whoami).
Changes:
- Reworked CLI command structure to
op <noun> <verb>and introduced new command groups (work-package,time-entry,project,budget,status,notification,user, etc.). - Added multi-profile configuration support (INI-based), along with
op whoami,op logout, and profile selection via--profile/OP_CLI_PROFILE. - Added semantic identifier handling for work packages/projects, JSON output rendering via a new renderer abstraction, and new resource capabilities (time entry create, budget list/inspect).
Reviewed changes
Copilot reviewed 95 out of 96 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| TODO.md | Tracks follow-up items discovered during review. |
| README.md | Updates documentation/examples to noun-first commands and multi-profile usage. |
| .gitignore | Adds built binary and Claude local settings file. |
| CLAUDE.md | Adds contributor/developer architecture and conventions documentation. |
| models/work_package.go | Adds DisplayId to support semantic display identifiers. |
| models/project.go | Adds Identifier to support slug/identifier-based project access. |
| models/time_entry_activity.go | Adds time entry activity model. |
| models/budget.go | Adds budget model. |
| dtos/work_package.go | Maps API displayId and hardens link/description conversion against nils. |
| dtos/project.go | Adds project identifier mapping. |
| dtos/time_entry.go | Renames/exports links DTO and hardens conversion against nil links/comment. |
| dtos/time_entry_activity.go | Adds DTOs and conversion for time entry activities collection. |
| dtos/budget.go | Adds budget DTOs and collection conversion. |
| components/paths/paths.go | Updates path helpers to accept string identifiers; adds budgets/time entry activities endpoints. |
| components/routes/routes.go | Changes browser routes to use wp/<displayId> and projects/<identifier>. |
| components/resources/work_packages/validate.go | Adds identifier validation for numeric vs semantic WP IDs. |
| components/resources/work_packages/validate_test.go | Tests new WP identifier validation. |
| components/resources/work_packages/read.go | Converts WP APIs to accept string IDs and adds parent filter handling. |
| components/resources/work_packages/filters.go | Adds Parent filter option and filter builder. |
| components/resources/work_packages/create.go | Allows project identifier string; adds assignee/description create options. |
| components/resources/work_packages/update.go | Allows string WP id; adds description patching and improves assignee parsing errors. |
| components/resources/work_packages/activities.go | Switches WP activities to accept string WP identifiers. |
| components/resources/time_entries/create.go | Implements time entry creation plus activity lookup. |
| components/resources/projects/functions.go | Updates project lookup to accept numeric ID or identifier and validates input. |
| components/resources/projects/versions.go | Updates versions API to take string project identifiers/IDs. |
| components/resources/projects/validate.go | Adds project identifier validation helper. |
| components/resources/projects/validate_test.go | Tests new project identifier validation. |
| components/resources/budgets/functions.go | Adds budgets lookup and listing for a project. |
| components/configuration/util.go | Removes legacy single-profile read/write logic (replaced by profiles). |
| components/configuration/profiles.go | Implements INI-based multi-profile config + migration from legacy format. |
| components/configuration/profiles_test.go | Comprehensive tests for profile sanitization/validation/read/write/migration. |
| components/printer/renderer.go | Introduces renderer abstraction and --format handling. |
| components/printer/renderer_test.go | Tests renderer init behavior on unknown/known formats. |
| components/printer/text_renderer.go | Centralizes existing text rendering logic behind renderer interface. |
| components/printer/json_renderer.go | Adds JSON output rendering for list/inspect commands. |
| components/printer/work_packages.go | Updates WP printing to use DisplayId via renderer and adds formatting helpers. |
| components/printer/work_packages_test.go | Updates tests for DisplayId and adds semantic display ID alignment test. |
| components/printer/projects.go | Routes project output via renderer. |
| components/printer/projects_test.go | Updates expected project output to include identifier. |
| components/printer/users.go | Routes user output via renderer. |
| components/printer/types.go | Routes type output via renderer. |
| components/printer/status.go | Routes status output via renderer. |
| components/printer/time_entries.go | Routes time entry output via renderer. |
| components/printer/notifications.go | Routes notifications output via renderer while keeping grouping helper. |
| components/printer/custom_actions.go | Routes custom actions output via renderer. |
| components/printer/numbers.go | Routes numeric output via renderer. |
| components/printer/budgets.go | Adds budgets printer entry points routed through renderer. |
| components/printer/budgets_test.go | Adds budgets output/alignment tests. |
| components/printer/whoami.go | Adds whoami printer entry point routed through renderer. |
| components/printer/whoami_test.go | Adds whoami output test. |
| components/printer/activities.go | Routes activities output via renderer. |
| components/printer/activities_test.go | Adds regression test for prior activities user-lookup bug. |
| cmd/root.go | Registers new noun-first command groups; adds --format and --profile; initializes renderer/requests in pre-run. |
| cmd/login.go | Adds profile selection/prompting and profile overwrite confirmation; stores credentials per profile. |
| cmd/logout.go | Adds logout command to remove stored profiles with confirmation. |
| cmd/whoami.go | Adds whoami command to display authenticated user for one/all profiles. |
| cmd/workpackage/workpackage.go | Adds new work-package command group and wires flags/subcommands. |
| cmd/workpackage/list.go | Implements work-package list with project/parent filters and improved errors. |
| cmd/workpackage/list_flags.go | Defines flags for work-package list (project, parent-id, filters, total, etc.). |
| cmd/workpackage/create.go | Implements work-package create supporting project identifier, assignee, description, open-in-browser. |
| cmd/workpackage/update.go | Implements work-package update supporting semantic IDs and new --description. |
| cmd/workpackage/options_test.go | Adds tests for description flag semantics in create/update. |
| cmd/workpackage/inspect.go | Implements work-package inspect supporting semantic IDs and listing types. |
| cmd/workpackage/workpackage_test.go | Adds test ensuring hyphenated command name usage. |
| cmd/timeentry/timeentry.go | Adds time-entry command group and wires list/create. |
| cmd/timeentry/list.go | Implements time-entry list as noun-first list subcommand. |
| cmd/timeentry/list_flags.go | Wires filters for time-entry list. |
| cmd/timeentry/create.go | Implements time-entry create including defaults and optional activity/user/comment. |
| cmd/timeentry/create_flags.go | Defines flags and required inputs for time-entry create. |
| cmd/timeentry/timeentry_test.go | Adds test ensuring hyphenated command name usage. |
| cmd/project/project.go | Adds project command group and wires list/inspect with open flag. |
| cmd/project/list.go | Implements project list as noun-first list subcommand. |
| cmd/project/inspect.go | Implements project inspect accepting numeric ID or identifier. |
| cmd/budget/budget.go | Adds budget command group and wires list/inspect. |
| cmd/budget/list.go | Implements `budget list --project <id |
| cmd/budget/inspect.go | Implements budget inspect <id>. |
| cmd/status/status.go | Adds status command group. |
| cmd/status/list.go | Implements status list. |
| cmd/notification/notification.go | Adds notification command group. |
| cmd/notification/list.go | Implements notification list with reason filter. |
| cmd/user/user.go | Adds user command group. |
| cmd/user/search.go | Implements user search (including me keyword behavior). |
| cmd/wptype/wptype.go | Adds type command group for work package types. |
| cmd/wptype/list.go | Implements type list. |
| cmd/activities/activities.go | Adds activities command group. |
| cmd/activities/list.go | Implements `activities list --work-package <id |
| cmd/git/start/work_package.go | Updates git start command to accept semantic WP identifiers. |
| cmd/update/update.go | Removes legacy verb-first update command group. |
| cmd/update/work_package.go | Removes legacy verb-first update workpackage command. |
| cmd/list/list.go | Removes legacy verb-first list command group. |
| cmd/list/activities.go | Removes legacy verb-first activities list command. |
| cmd/list/status.go | Removes legacy verb-first status list command. |
| cmd/inspect/inspect.go | Removes legacy verb-first inspect command group. |
| cmd/create/create.go | Removes legacy verb-first create command group. |
| cmd/create/work_package.go | Removes legacy verb-first create workpackage command. |
| cmd/search/search.go | Removes legacy verb-first search command group. |
Comments suppressed due to low confidence (2)
cmd/workpackage/list.go:156
- validatedVersionId() logs an error but continues, which can lead to a nil dereference (project may be nil, yet project.Identifier is accessed on the next lines). It should abort/return immediately when projects.Lookup fails.
cmd/workpackage/list.go:161 - validatedVersionId() logs an error but continues, which can lead to using an uninitialized versions slice (and inconsistent CLI behavior). Exit/return when AvailableVersions fails.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Handle url.Parse error in PersistentPreRunE instead of silently ignoring it - Route printJson output through activePrinter instead of fmt directly - Use printer.Input for API token prompt in login instead of fmt.Printf - Add comment explaining DisplayId is always non-empty (API guarantees identifier.presence || id)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 95 out of 96 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
cmd/workpackage/list.go:156
- In validatedVersionId(), errors from projects.Lookup() / AvailableVersions() are printed but the function continues, which can dereference a nil project (project.Identifier) and/or operate on a nil versions slice. Either return/exit immediately after these errors, or propagate the error to the caller so list can handle it cleanly.
| // Old format: no section headers | ||
| if !strings.Contains(content, "[") && content != "" { | ||
| clean := common.SanitizeLineBreaks(content) | ||
| parts := strings.SplitN(clean, " ", 2) | ||
| if len(parts) == 2 && parts[0] != "" && parts[1] != "" { | ||
| f := newIniFile() | ||
| f.set(DefaultProfile, "host", parts[0]) | ||
| f.set(DefaultProfile, "token", parts[1]) | ||
| return f, true | ||
| } | ||
| } |
| /////////////// MODEL CONVERSION /////////////// | ||
|
|
||
| func (dto *BudgetCollectionDto) Convert() []*models.Budget { | ||
| budgets := make([]*models.Budget, len(dto.Embedded.Elements)) |
There was a problem hiding this comment.
_embedded object - e.g. it is optional for soem error responses. As such this needs a nil guard.
| printer.ErrorText(fmt.Sprintf("--parent-id: %s", err.Error())) | ||
| return | ||
| } | ||
| if _, err := work_packages.Lookup(listParentId); err != nil { |
| } | ||
| host, _ = f.get(profile, "host") | ||
| token, _ = f.get(profile, "token") | ||
| return host, token, nil |
There was a problem hiding this comment.
Claude 🤖 findings:
Automated finding, not personally verified by the PR author.
The old ReadConfig surfaced an explicit "Invalid config file… please remove the file and run op login again" error. Here readConfigForProfile returns empty creds with a nil error for a malformed/un-resolvable config, so the user only sees the generic "not logged in" message and is never told the file is corrupt or how to fix it. Consider returning a diagnostic error when a config file exists but yields no usable profile.
| if !strings.Contains(content, "[") && content != "" { | ||
| clean := common.SanitizeLineBreaks(content) | ||
| parts := strings.SplitN(clean, " ", 2) | ||
| if len(parts) == 2 && parts[0] != "" && parts[1] != "" { |
There was a problem hiding this comment.
Claude 🤖 findings:
Automated finding, not personally verified by the PR author.
Old-format migration requires both SplitN parts non-empty. A malformed old file (host-only, token-only, leading/trailing space) silently fails to migrate, falls through to parseIni which finds no section, and ReadConfig returns empty creds with no error — an existing logged-in user is silently treated as logged out after upgrade with no diagnostic.
| sb.WriteString("\n") | ||
| } | ||
| sb.WriteString(fmt.Sprintf("[%s]\n", s.name)) | ||
| for _, key := range []string{"host", "token"} { |
There was a problem hiding this comment.
Claude 🤖 findings: (cleanup)
Automated finding, not personally verified by the PR author.
marshal() only emits host/token, so any other key present in a profile section is silently discarded on rewrite — latent data loss if the config format ever grows additional keys. Consider iterating the section's actual keys.
| func WorkPackageUrl(workPackage *models.WorkPackage) *url.URL { | ||
| routeUrl := *host | ||
| routeUrl.Path = fmt.Sprintf("work_packages/%d", workPackage.Id) | ||
| // DisplayId is always non-empty: the server code sets it to `identifier.presence || id` |
There was a problem hiding this comment.
Claude 🤖 findings:
Automated finding, not personally verified by the PR author.
op work-package create -o opens host/wp/ (no id) when the create POST response omits displayId (omitempty). Worth guarding against an empty DisplayId before building the URL.
There was a problem hiding this comment.
@myabc Can displayId ever be empty? My current assumption is that it can't be empty.
| func Project(id uint64) string { | ||
| return Projects() + fmt.Sprintf("/%d", id) | ||
| func Project(id string) string { | ||
| return Projects() + "/" + id |
There was a problem hiding this comment.
Claude 🤖 findings:
Automated finding, not personally verified by the PR author.
Project(id) / WorkPackage(id) now concatenate a caller-supplied string identifier directly with no escaping. The project identifier validator permits +, which is path-significant and read as a space by many servers, producing a wrong-resource lookup. Consider url.PathEscape on the identifier.
|
|
||
| func hoursCreate(entry *dtos.TimeEntryDto, input string) error { | ||
| var hours float64 | ||
| if _, err := fmt.Sscanf(input, "%f", &hours); err != nil { |
There was a problem hiding this comment.
Claude 🤖 findings:
Automated finding, not personally verified by the PR author.
fmt.Sscanf(input, "%f", …) (and "%d" at line 123) scans one item with a nil error for input like 1.5abc (→ 1.5) or 12x (→ user 12), silently accepting invalid input rather than rejecting it. Prefer strconv.ParseFloat / strconv.ParseUint, which fail on trailing characters.
| return nil | ||
| } | ||
|
|
||
| func hoursToISO8601(hours float64) string { |
There was a problem hiding this comment.
Claude 🤖 findings: (cleanup)
Automated finding, not personally verified by the PR author.
hoursToISO8601 hand-rolls ISO-8601 duration formatting, while github.com/sosodev/duration is already a dependency used on the read path (dtos/time_entry.go:42). Reusing it keeps the write path in lockstep with parsing.
| } | ||
|
|
||
| func (r *JsonRenderer) WorkPackage(wp *models.WorkPackage) { | ||
| printJson(struct { |
There was a problem hiding this comment.
Claude 🤖 findings: (cleanup)
Automated finding, not personally verified by the PR author.
Each resource declares its field shape twice (single + list anonymous structs); these have already diverged — WorkPackage includes Description while WorkPackages omits it. A single named view struct per resource, reused for both, keeps single/list output in sync.
| // - If OP_CLI_PROFILE is set (but --profile was not): display the value | ||
| // and use it without prompting. | ||
| // - Otherwise: prompt the user interactively. | ||
| func resolveLoginProfile(cmd *cobra.Command) (string, error) { |
There was a problem hiding this comment.
Claude 🤖 findings: (cleanup)
Automated finding, not personally verified by the PR author.
resolveLoginProfile and resolvedProfile (cmd/root.go:103) both reimplement the flag > env > default precedence and can drift. Consider a single shared resolver.
|
@myabc 9 comments with
What makes you think I have more time available than you to actually verify them? I too have other things to do. From now on I'll ignore all non-human comments posted by humans. |
|
@myabc And also feel free to send patches rather than comments. We all collaborate on this. |
- Guard against nil _embedded in BudgetCollectionDto.Convert - Resolve --parent-id semantic identifier to numeric ID before filtering - Use strconv.ParseFloat/ParseUint instead of fmt.Sscanf to reject trailing garbage (e.g. "1.5abc") - Fix IPv6 URL mis-detection in legacy config migration (HasPrefix instead of Contains) - Add regression test for IPv6 host migration
Searches across subject, type, status, project name, and identifier using the OpenProject API typeahead filter. Multiple words are ANDed. Returns up to 100 results.
Scopes the search to a specific project when provided, using the project-scoped API endpoint. Accepts a numeric ID or identifier.

Summary
This PR brings a series of improvements to the OpenProject CLI, built incrementally
over ~20 commits. The changes span command naming, new features, and identifier handling.
Sorry for not splitting it in multiple PRs, but I don't have much time for that, and each commits build on top of each other.
Command restructuring (breaking change)
Commands now follow
op <noun> <verb>instead ofop <verb> <noun>. The oldverb-first syntax has been removed entirely:
op list workpackagesop work-package listop create workpackageop work-package createop update workpackageop work-package updateop list time-entriesop time-entry listEach noun (
work-package,time-entry,project,budget, …) is now its ownsubcommand group with discoverable verbs and consistent flag help.
Multi-profile support
op loginnow supports named profiles for working with multiple OpenProject instances:The
OP_CLI_PROFILEenv var selects a profile for the whole session.New commands
op whoami— shows all configured profiles and the authenticated user for eachop time-entry create— creates a time entry with activity, hours, date, commentop budget list/op budget inspect— lists and inspects project budgetsop work-package search— searches work packages by subject, type, status, project name, or identifier; multiple words are ANDed; returns up to 100 resultsNew flags
op work-package list --parent-id— filter by parent work packageop work-package create/update --assignee— assign a userop work-package create/update --description— set descriptionSemantic work package identifiers
Work packages can now be referenced by their project-based identifier wherever a
numeric ID was previously required. Both slug-based identifiers (e.g.
PROJ-42,used before semantic identifiers were introduced) and semantic identifiers
(e.g.
PROJ-123) are supported. ThedisplayIdfield from the API drives display:semantic form when project-based identifiers are enabled,
#Notherwise.JSON output
All list/inspect commands support
--format jsonfor machine-readable output.Project identifiers
Project path arguments now accept either a numeric ID or a human-readable slug
(e.g.
my-project), matching what the OpenProject API supports natively.Developer docs
Added
CLAUDE.mddocumenting the architecture, conventions, and data flow forcontributors.
Suggested test plan
go test ./...passesop login --profile <name>creates and stores a named profileop whoamilists all profiles with their URLs and usersop work-package list --format jsonreturns valid JSONop work-package list --parent-id <id>filters correctlyop time-entry createprompts for missing required fields and submitsPROJ-42orPROJ-123in inspect/update/openop budget listandop budget inspectwork on a project with budgetsop work-package search cascadereturns matching work packagesop work-package search fix loginANDs both terms