From cb0b7669b3aeb0c34307d22ce93fc4fe7c3e557d Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 13:34:41 +0000 Subject: [PATCH 01/15] feat: scaffold Go-based hf CLI --- .gitignore | 11 ++++++----- README.md | 40 +++++++++++++++++++++++++++++++++++--- cmd/hf/main.go | 24 +++++++++++++++++++++++ go.mod | 3 +++ internal/client/.gitkeep | 0 internal/client/doc.go | 3 +++ internal/commands/.gitkeep | 0 internal/commands/doc.go | 3 +++ internal/config/.gitkeep | 0 internal/config/doc.go | 3 +++ internal/help/.gitkeep | 0 internal/help/doc.go | 3 +++ internal/mode/.gitkeep | 0 internal/mode/doc.go | 3 +++ internal/passmgr/.gitkeep | 0 internal/passmgr/doc.go | 3 +++ 16 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 cmd/hf/main.go create mode 100644 go.mod create mode 100644 internal/client/.gitkeep create mode 100644 internal/client/doc.go create mode 100644 internal/commands/.gitkeep create mode 100644 internal/commands/doc.go create mode 100644 internal/config/.gitkeep create mode 100644 internal/config/doc.go create mode 100644 internal/help/.gitkeep create mode 100644 internal/help/doc.go create mode 100644 internal/mode/.gitkeep create mode 100644 internal/mode/doc.go create mode 100644 internal/passmgr/.gitkeep create mode 100644 internal/passmgr/doc.go diff --git a/.gitignore b/.gitignore index 8b998a8..7f840a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -dist/ bin/ -node_modules/ -*.log -.env -.env.* +dist/ +coverage.out +*.test +*.exe +*.out +vendor/ diff --git a/README.md b/README.md index b866f9e..6230347 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,40 @@ # HarborForge.Cli -CLI tools for HarborForge. +`HarborForge.Cli` is the home of the new Go-based `hf` binary for HarborForge. -This repository is intentionally initialized with a minimal scaffold. -Future commands and packaging can be added here. +## Current status + +This repository now contains the initial Go scaffold required by the cross-project plan: + +- Go module initialization +- binary entrypoint at `cmd/hf/main.go` +- placeholder internal package layout for future implementation +- basic build instructions + +## Build + +```bash +go build -o ./bin/hf ./cmd/hf +``` + +## Run + +```bash +go run ./cmd/hf --help +go run ./cmd/hf version +``` + +## Planned package layout + +```text +cmd/hf/ +internal/ + client/ + commands/ + config/ + help/ + mode/ + passmgr/ +``` + +The scaffold is intentionally minimal so follow-up work can implement config loading, mode detection, help rendering, auth, and API command groups incrementally. diff --git a/cmd/hf/main.go b/cmd/hf/main.go new file mode 100644 index 0000000..7176e8d --- /dev/null +++ b/cmd/hf/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "--help", "-h": + fmt.Println("hf - HarborForge CLI") + fmt.Println() + fmt.Println("This is the initial Go scaffold for the HarborForge CLI.") + fmt.Println("More command groups will be added in follow-up tasks.") + return + case "version": + fmt.Println("hf dev") + return + } + } + + fmt.Println("hf - HarborForge CLI scaffold") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a97452f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.hangman-lab.top/zhi/HarborForge.Cli + +go 1.22 diff --git a/internal/client/.gitkeep b/internal/client/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/client/doc.go b/internal/client/doc.go new file mode 100644 index 0000000..054e459 --- /dev/null +++ b/internal/client/doc.go @@ -0,0 +1,3 @@ +package client + +// Package client will host HarborForge HTTP client helpers. diff --git a/internal/commands/.gitkeep b/internal/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/commands/doc.go b/internal/commands/doc.go new file mode 100644 index 0000000..745b171 --- /dev/null +++ b/internal/commands/doc.go @@ -0,0 +1,3 @@ +package commands + +// Package commands will define the hf command tree. diff --git a/internal/config/.gitkeep b/internal/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..3706e51 --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,3 @@ +package config + +// Package config will resolve and manage .hf-config.json. diff --git a/internal/help/.gitkeep b/internal/help/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/help/doc.go b/internal/help/doc.go new file mode 100644 index 0000000..c9259ff --- /dev/null +++ b/internal/help/doc.go @@ -0,0 +1,3 @@ +package help + +// Package help will render help and help-brief output. diff --git a/internal/mode/.gitkeep b/internal/mode/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/mode/doc.go b/internal/mode/doc.go new file mode 100644 index 0000000..5bd3ec0 --- /dev/null +++ b/internal/mode/doc.go @@ -0,0 +1,3 @@ +package mode + +// Package mode will detect padded-cell/manual runtime behavior. diff --git a/internal/passmgr/.gitkeep b/internal/passmgr/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/passmgr/doc.go b/internal/passmgr/doc.go new file mode 100644 index 0000000..89ef8f8 --- /dev/null +++ b/internal/passmgr/doc.go @@ -0,0 +1,3 @@ +package passmgr + +// Package passmgr will integrate with pass_mgr when available. -- 2.49.1 From 7d3cff7d950d2e7dcfcc13bd44656e7f33f35c30 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 13:50:29 +0000 Subject: [PATCH 02/15] feat: implement core CLI packages and Phase 3 commands - config: resolve binary dir, load/save .hf-config.json - mode: detect padded-cell vs manual mode via pass_mgr - client: HTTP client wrapper with auth header support - passmgr: pass_mgr integration (get-secret, set, generate) - output: human-readable + JSON output formatting with tables - help: help and help-brief renderer for groups/commands - commands: version, health, config (--url, --acc-mgr-token, show) - auth: token resolution helper (padded-cell auto / manual explicit) - main: command dispatcher with --json global flag support - README: updated with current package layout and status --- README.md | 77 +++++++++++++++++------- cmd/hf/main.go | 111 ++++++++++++++++++++++++++++++---- internal/client/.gitkeep | 0 internal/client/client.go | 105 ++++++++++++++++++++++++++++++++ internal/client/doc.go | 3 - internal/commands/.gitkeep | 0 internal/commands/auth.go | 36 +++++++++++ internal/commands/config.go | 53 ++++++++++++++++ internal/commands/doc.go | 3 - internal/commands/health.go | 32 ++++++++++ internal/commands/version.go | 19 ++++++ internal/config/.gitkeep | 0 internal/config/config.go | 92 ++++++++++++++++++++++++++++ internal/config/doc.go | 3 - internal/help/.gitkeep | 0 internal/help/doc.go | 3 - internal/help/help.go | 113 +++++++++++++++++++++++++++++++++++ internal/mode/.gitkeep | 0 internal/mode/doc.go | 3 - internal/mode/mode.go | 53 ++++++++++++++++ internal/output/output.go | 93 ++++++++++++++++++++++++++++ internal/passmgr/.gitkeep | 0 internal/passmgr/doc.go | 3 - internal/passmgr/passmgr.go | 60 +++++++++++++++++++ 24 files changed, 810 insertions(+), 52 deletions(-) delete mode 100644 internal/client/.gitkeep create mode 100644 internal/client/client.go delete mode 100644 internal/client/doc.go delete mode 100644 internal/commands/.gitkeep create mode 100644 internal/commands/auth.go create mode 100644 internal/commands/config.go delete mode 100644 internal/commands/doc.go create mode 100644 internal/commands/health.go create mode 100644 internal/commands/version.go delete mode 100644 internal/config/.gitkeep create mode 100644 internal/config/config.go delete mode 100644 internal/config/doc.go delete mode 100644 internal/help/.gitkeep delete mode 100644 internal/help/doc.go create mode 100644 internal/help/help.go delete mode 100644 internal/mode/.gitkeep delete mode 100644 internal/mode/doc.go create mode 100644 internal/mode/mode.go create mode 100644 internal/output/output.go delete mode 100644 internal/passmgr/.gitkeep delete mode 100644 internal/passmgr/doc.go create mode 100644 internal/passmgr/passmgr.go diff --git a/README.md b/README.md index 6230347..8ceafdf 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,6 @@ # HarborForge.Cli -`HarborForge.Cli` is the home of the new Go-based `hf` binary for HarborForge. - -## Current status - -This repository now contains the initial Go scaffold required by the cross-project plan: - -- Go module initialization -- binary entrypoint at `cmd/hf/main.go` -- placeholder internal package layout for future implementation -- basic build instructions +`HarborForge.Cli` is the Go-based `hf` binary for HarborForge. ## Build @@ -17,24 +8,68 @@ This repository now contains the initial Go scaffold required by the cross-proje go build -o ./bin/hf ./cmd/hf ``` +To set the version at build time: + +```bash +go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=1.0.0" -o ./bin/hf ./cmd/hf +``` + ## Run ```bash -go run ./cmd/hf --help -go run ./cmd/hf version +# Show help +./bin/hf --help + +# Show only permitted commands +./bin/hf --help-brief + +# Show version +./bin/hf version + +# Check API health +./bin/hf health + +# Configure API URL +./bin/hf config --url http://your-harborforge:8000 + +# View current config +./bin/hf config + +# JSON output +./bin/hf version --json ``` -## Planned package layout +## Package Layout ```text -cmd/hf/ +cmd/hf/ CLI entrypoint internal/ - client/ - commands/ - config/ - help/ - mode/ - passmgr/ + client/ HTTP client wrapper for HarborForge API + commands/ Command implementations (version, health, config, auth helpers) + config/ Config file resolution and management (.hf-config.json) + help/ Help and help-brief renderer + mode/ Runtime mode detection (padded-cell vs manual) + output/ Output formatting (human-readable, JSON, tables) + passmgr/ pass_mgr integration for secret resolution ``` -The scaffold is intentionally minimal so follow-up work can implement config loading, mode detection, help rendering, auth, and API command groups incrementally. +## Runtime Modes + +- **Padded-cell mode**: When `pass_mgr` is available, auth tokens are resolved automatically. Manual `--token` flags are rejected. +- **Manual mode**: When `pass_mgr` is not available, `--token` must be provided explicitly to authenticated commands. + +## Current Status + +Implemented: +- Go module and binary entrypoint +- Config file resolution relative to binary directory +- Runtime mode detection (`pass_mgr` present/absent) +- Help and help-brief rendering system +- HTTP client wrapper +- Output formatting (human-readable + `--json`) +- `hf version`, `hf health`, `hf config` +- Auth token resolution (padded-cell + manual) + +Planned: +- User, role, project, task, milestone, meeting, support, propose, monitor commands +- Permission-aware help rendering diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 7176e8d..92ce0f4 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -3,22 +3,107 @@ package main import ( "fmt" "os" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/help" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" ) func main() { - if len(os.Args) > 1 { - switch os.Args[1] { - case "--help", "-h": - fmt.Println("hf - HarborForge CLI") - fmt.Println() - fmt.Println("This is the initial Go scaffold for the HarborForge CLI.") - fmt.Println("More command groups will be added in follow-up tasks.") - return - case "version": - fmt.Println("hf dev") - return - } + args := os.Args[1:] + + // Parse global flags first + args = parseGlobalFlags(args) + + if len(args) == 0 { + fmt.Print(help.RenderTopHelp(commands.Version, topGroups())) + return } - fmt.Println("hf - HarborForge CLI scaffold") + switch args[0] { + case "--help", "-h": + fmt.Print(help.RenderTopHelp(commands.Version, topGroups())) + case "--help-brief": + fmt.Print(help.RenderTopHelpBrief(commands.Version, topGroups())) + case "version": + commands.RunVersion() + case "health": + commands.RunHealth() + case "config": + runConfig(args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) + fmt.Fprintf(os.Stderr, "Run 'hf --help' for usage.\n") + os.Exit(1) + } +} + +// parseGlobalFlags extracts --json from anywhere in the args and returns remaining args. +func parseGlobalFlags(args []string) []string { + var remaining []string + for _, a := range args { + switch a { + case "--json": + output.JSONMode = true + default: + remaining = append(remaining, a) + } + } + return remaining +} + +func runConfig(args []string) { + if len(args) == 0 { + commands.RunConfigShow() + return + } + for i := 0; i < len(args); i++ { + switch args[i] { + case "--url": + if i+1 >= len(args) { + output.Error("usage: hf config --url ") + } + commands.RunConfigURL(args[i+1]) + return + case "--acc-mgr-token": + if i+1 >= len(args) { + output.Error("usage: hf config --acc-mgr-token ") + } + commands.RunConfigAccMgrToken(args[i+1]) + return + case "--help", "-h": + fmt.Println("hf config - View and manage CLI configuration") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" hf config Show current config") + fmt.Println(" hf config --url Set HarborForge API URL") + if !mode.IsPaddedCell() { + fmt.Println(" hf config --acc-mgr-token Set account-manager token") + } + return + default: + output.Errorf("unknown config flag: %s", args[i]) + } + } +} + +// topGroups returns the full command tree for help rendering. +// TODO: permission awareness will be added when auth introspection is available. +func topGroups() []help.Group { + return []help.Group{ + {Name: "version", Description: "Show CLI version", Permitted: true}, + {Name: "health", Description: "Check API health", Permitted: true}, + {Name: "config", Description: "View and manage CLI configuration", Permitted: true}, + {Name: "user", Description: "Manage users", Permitted: true}, + {Name: "role", Description: "Manage roles and permissions", Permitted: true}, + {Name: "permission", Description: "List permissions", Permitted: true}, + {Name: "project", Description: "Manage projects", Permitted: true}, + {Name: "milestone", Description: "Manage milestones", Permitted: true}, + {Name: "task", Description: "Manage tasks", Permitted: true}, + {Name: "meeting", Description: "Manage meetings", Permitted: true}, + {Name: "support", Description: "Manage support tickets", Permitted: true}, + {Name: "propose", Description: "Manage proposals", Permitted: true}, + {Name: "monitor", Description: "Monitor servers and API keys", Permitted: true}, + } } diff --git a/internal/client/.gitkeep b/internal/client/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..eccd18e --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,105 @@ +// Package client provides the HTTP client wrapper for HarborForge API calls. +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client is a simple HarborForge API client. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// New creates a Client with the given base URL and optional auth token. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// RequestError represents a non-2xx HTTP response. +type RequestError struct { + StatusCode int + Body string +} + +func (e *RequestError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body) +} + +// Do executes an HTTP request and returns the response body bytes. +func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) { + url := c.BaseURL + path + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("cannot create request: %w", err) + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cannot read response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, &RequestError{StatusCode: resp.StatusCode, Body: string(data)} + } + return data, nil +} + +// Get performs a GET request. +func (c *Client) Get(path string) ([]byte, error) { + return c.Do("GET", path, nil) +} + +// Post performs a POST request with a JSON body. +func (c *Client) Post(path string, body io.Reader) ([]byte, error) { + return c.Do("POST", path, body) +} + +// Put performs a PUT request with a JSON body. +func (c *Client) Put(path string, body io.Reader) ([]byte, error) { + return c.Do("PUT", path, body) +} + +// Patch performs a PATCH request with a JSON body. +func (c *Client) Patch(path string, body io.Reader) ([]byte, error) { + return c.Do("PATCH", path, body) +} + +// Delete performs a DELETE request. +func (c *Client) Delete(path string) ([]byte, error) { + return c.Do("DELETE", path, nil) +} + +// Health checks the API health endpoint and returns the response. +func (c *Client) Health() (map[string]interface{}, error) { + data, err := c.Get("/api/health/") + if err != nil { + return nil, err + } + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("cannot parse health response: %w", err) + } + return result, nil +} diff --git a/internal/client/doc.go b/internal/client/doc.go deleted file mode 100644 index 054e459..0000000 --- a/internal/client/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package client - -// Package client will host HarborForge HTTP client helpers. diff --git a/internal/commands/.gitkeep b/internal/commands/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/commands/auth.go b/internal/commands/auth.go new file mode 100644 index 0000000..1c42f2a --- /dev/null +++ b/internal/commands/auth.go @@ -0,0 +1,36 @@ +package commands + +import ( + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" +) + +// ResolveToken resolves the auth token based on runtime mode. +// In padded-cell mode, tokenFlag must be empty (enforced). +// In manual mode, tokenFlag is required. +func ResolveToken(tokenFlag string) string { + if mode.IsPaddedCell() { + if tokenFlag != "" { + output.Error("padded-cell installed, --token flag disabled, use command directly") + } + tok, err := passmgr.GetToken() + if err != nil { + output.Errorf("cannot resolve token: %v", err) + } + return tok + } + // manual mode + if tokenFlag == "" { + output.Error("--token required or execute this with pcexec") + } + return tokenFlag +} + +// RejectTokenInPaddedCell checks if --token was passed in padded-cell mode +// and terminates with the standard error message. +func RejectTokenInPaddedCell(tokenFlag string) { + if mode.IsPaddedCell() && tokenFlag != "" { + output.Error("padded-cell installed, --token flag disabled, use command directly") + } +} diff --git a/internal/commands/config.go b/internal/commands/config.go new file mode 100644 index 0000000..54a895a --- /dev/null +++ b/internal/commands/config.go @@ -0,0 +1,53 @@ +package commands + +import ( + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" +) + +// RunConfigURL sets the base URL in the config file. +func RunConfigURL(url string) { + if url == "" { + output.Error("usage: hf config --url ") + } + if err := config.UpdateURL(url); err != nil { + output.Errorf("failed to update config: %v", err) + } + fmt.Printf("base-url set to %s\n", url) +} + +// RunConfigAccMgrToken stores the account-manager token via pass_mgr. +func RunConfigAccMgrToken(token string) { + if token == "" { + output.Error("usage: hf config --acc-mgr-token ") + } + if !mode.IsPaddedCell() { + output.Error("--acc-mgr-token can only be set with padded-cell plugin") + } + if err := passmgr.SetAccountManagerToken(token); err != nil { + output.Errorf("failed to store acc-mgr-token: %v", err) + } + fmt.Println("account-manager token stored successfully") +} + +// RunConfigShow displays the current config. +func RunConfigShow() { + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + if output.JSONMode { + output.PrintJSON(cfg) + } else { + p, _ := config.ConfigPath() + output.PrintKeyValue( + "base-url", cfg.BaseURL, + "config-file", p, + "mode", mode.Detect().String(), + ) + } +} diff --git a/internal/commands/doc.go b/internal/commands/doc.go deleted file mode 100644 index 745b171..0000000 --- a/internal/commands/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package commands - -// Package commands will define the hf command tree. diff --git a/internal/commands/health.go b/internal/commands/health.go new file mode 100644 index 0000000..7140237 --- /dev/null +++ b/internal/commands/health.go @@ -0,0 +1,32 @@ +package commands + +import ( + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// RunHealth checks the HarborForge API health endpoint. +func RunHealth() { + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, "") + result, err := c.Health() + if err != nil { + output.Errorf("health check failed: %v", err) + } + if output.JSONMode { + output.PrintJSON(result) + } else { + status, _ := result["status"].(string) + if status == "" { + status = "unknown" + } + fmt.Printf("HarborForge API: %s\n", status) + fmt.Printf("URL: %s\n", cfg.BaseURL) + } +} diff --git a/internal/commands/version.go b/internal/commands/version.go new file mode 100644 index 0000000..90865f0 --- /dev/null +++ b/internal/commands/version.go @@ -0,0 +1,19 @@ +package commands + +import ( + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// Version is the CLI version string, set at build time via ldflags. +var Version = "dev" + +// RunVersion prints the CLI version. +func RunVersion() { + if output.JSONMode { + output.PrintJSON(map[string]string{"version": Version}) + } else { + fmt.Printf("hf %s\n", Version) + } +} diff --git a/internal/config/.gitkeep b/internal/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b4cb5f6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,92 @@ +// Package config resolves and manages .hf-config.json relative to the binary directory. +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const configFileName = ".hf-config.json" + +// Config holds the CLI configuration. +type Config struct { + BaseURL string `json:"base-url"` +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() Config { + return Config{ + BaseURL: "http://127.0.0.1:8000", + } +} + +// BinaryDir returns the directory containing the running binary. +func BinaryDir() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("cannot resolve binary path: %w", err) + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return "", fmt.Errorf("cannot resolve binary symlinks: %w", err) + } + return filepath.Dir(exe), nil +} + +// ConfigPath returns the full path to the config file. +func ConfigPath() (string, error) { + dir, err := BinaryDir() + if err != nil { + return "", err + } + return filepath.Join(dir, configFileName), nil +} + +// Load reads the config file. If the file does not exist, returns DefaultConfig. +func Load() (Config, error) { + p, err := ConfigPath() + if err != nil { + return DefaultConfig(), err + } + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return DefaultConfig(), nil + } + return DefaultConfig(), fmt.Errorf("cannot read config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return DefaultConfig(), fmt.Errorf("invalid config JSON: %w", err) + } + if cfg.BaseURL == "" { + cfg.BaseURL = DefaultConfig().BaseURL + } + return cfg, nil +} + +// Save writes the config to the config file path. +func Save(cfg Config) error { + p, err := ConfigPath() + if err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("cannot marshal config: %w", err) + } + data = append(data, '\n') + if err := os.WriteFile(p, data, 0644); err != nil { + return fmt.Errorf("cannot write config: %w", err) + } + return nil +} + +// UpdateURL loads the existing config, updates the base-url, and saves. +func UpdateURL(url string) error { + cfg, _ := Load() + cfg.BaseURL = url + return Save(cfg) +} diff --git a/internal/config/doc.go b/internal/config/doc.go deleted file mode 100644 index 3706e51..0000000 --- a/internal/config/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package config - -// Package config will resolve and manage .hf-config.json. diff --git a/internal/help/.gitkeep b/internal/help/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/help/doc.go b/internal/help/doc.go deleted file mode 100644 index c9259ff..0000000 --- a/internal/help/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package help - -// Package help will render help and help-brief output. diff --git a/internal/help/help.go b/internal/help/help.go new file mode 100644 index 0000000..04788ee --- /dev/null +++ b/internal/help/help.go @@ -0,0 +1,113 @@ +// Package help renders help and help-brief output for the hf CLI. +package help + +import ( + "fmt" + "strings" +) + +// Command describes a CLI command or group. +type Command struct { + Name string + Description string + Permitted bool // whether the current user can execute this + SubCommands []Command +} + +// Group describes a top-level command group. +type Group struct { + Name string + Description string + Permitted bool + SubCommands []Command +} + +// RenderTopHelp renders `hf --help` output showing all groups. +func RenderTopHelp(version string, groups []Group) string { + var b strings.Builder + b.WriteString("hf - HarborForge CLI") + if version != "" { + b.WriteString(" (" + version + ")") + } + b.WriteString("\n\nUsage: hf [flags]\n\n") + b.WriteString("Commands:\n") + maxLen := 0 + for _, g := range groups { + if len(g.Name) > maxLen { + maxLen = len(g.Name) + } + } + for _, g := range groups { + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, g.Name, g.Description)) + } + b.WriteString("\nGlobal flags:\n") + b.WriteString(" --help Show help\n") + b.WriteString(" --help-brief Show only permitted commands\n") + b.WriteString(" --json Output in JSON format\n") + return b.String() +} + +// RenderTopHelpBrief renders `hf --help-brief` showing only permitted groups. +func RenderTopHelpBrief(version string, groups []Group) string { + var b strings.Builder + b.WriteString("hf - HarborForge CLI") + if version != "" { + b.WriteString(" (" + version + ")") + } + b.WriteString("\n\nPermitted commands:\n") + maxLen := 0 + for _, g := range groups { + if g.Permitted && len(g.Name) > maxLen { + maxLen = len(g.Name) + } + } + for _, g := range groups { + if g.Permitted { + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, g.Name, g.Description)) + } + } + return b.String() +} + +// RenderGroupHelp renders `hf --help` showing all subcommands. +func RenderGroupHelp(groupName string, cmds []Command) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("hf %s - subcommands:\n\n", groupName)) + maxLen := 0 + for _, c := range cmds { + if len(c.Name) > maxLen { + maxLen = len(c.Name) + } + } + for _, c := range cmds { + desc := c.Description + if !c.Permitted { + desc = "(not permitted)" + } + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, c.Name, desc)) + } + return b.String() +} + +// RenderGroupHelpBrief renders `hf --help-brief` showing only permitted subcommands. +func RenderGroupHelpBrief(groupName string, cmds []Command) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("hf %s - permitted subcommands:\n\n", groupName)) + maxLen := 0 + for _, c := range cmds { + if c.Permitted && len(c.Name) > maxLen { + maxLen = len(c.Name) + } + } + for _, c := range cmds { + if c.Permitted { + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, c.Name, c.Description)) + } + } + return b.String() +} + +// RenderNotPermitted renders the short message for an unpermitted leaf command. +func RenderNotPermitted(group, cmd string) string { + return fmt.Sprintf("hf %s %s: not permitted", group, cmd) +} diff --git a/internal/mode/.gitkeep b/internal/mode/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/mode/doc.go b/internal/mode/doc.go deleted file mode 100644 index 5bd3ec0..0000000 --- a/internal/mode/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package mode - -// Package mode will detect padded-cell/manual runtime behavior. diff --git a/internal/mode/mode.go b/internal/mode/mode.go new file mode 100644 index 0000000..3c52c1f --- /dev/null +++ b/internal/mode/mode.go @@ -0,0 +1,53 @@ +// Package mode detects whether the CLI runs in padded-cell mode or manual mode. +package mode + +import ( + "os/exec" + "sync" +) + +// RuntimeMode represents the CLI operating mode. +type RuntimeMode int + +const ( + // ManualMode requires explicit --token / --acc-mgr-token flags. + ManualMode RuntimeMode = iota + // PaddedCellMode resolves secrets via pass_mgr automatically. + PaddedCellMode +) + +var ( + detectedMode RuntimeMode + detectOnce sync.Once +) + +// Detect checks whether pass_mgr is available and returns the runtime mode. +// The result is cached after the first call. +func Detect() RuntimeMode { + detectOnce.Do(func() { + _, err := exec.LookPath("pass_mgr") + if err == nil { + detectedMode = PaddedCellMode + } else { + detectedMode = ManualMode + } + }) + return detectedMode +} + +// IsPaddedCell is a convenience helper. +func IsPaddedCell() bool { + return Detect() == PaddedCellMode +} + +// String returns a human-readable mode name. +func (m RuntimeMode) String() string { + switch m { + case PaddedCellMode: + return "padded-cell" + case ManualMode: + return "manual" + default: + return "unknown" + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..c337f6e --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,93 @@ +// Package output handles human-readable and JSON output formatting. +package output + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// JSONMode is true when --json flag is provided. +var JSONMode bool + +// PrintJSON prints data as indented JSON to stdout. +func PrintJSON(v interface{}) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "error: cannot marshal JSON: %v\n", err) + os.Exit(1) + } + fmt.Println(string(data)) +} + +// PrintKeyValue prints a set of key-value pairs in human-readable format. +func PrintKeyValue(pairs ...string) { + if len(pairs)%2 != 0 { + pairs = append(pairs, "") + } + maxKeyLen := 0 + for i := 0; i < len(pairs); i += 2 { + if len(pairs[i]) > maxKeyLen { + maxKeyLen = len(pairs[i]) + } + } + for i := 0; i < len(pairs); i += 2 { + fmt.Printf("%-*s %s\n", maxKeyLen, pairs[i]+":", pairs[i+1]) + } +} + +// PrintTable prints a simple table with headers and rows. +func PrintTable(headers []string, rows [][]string) { + if len(rows) == 0 { + fmt.Println("(no results)") + return + } + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(widths) && len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + // header + parts := make([]string, len(headers)) + for i, h := range headers { + parts[i] = fmt.Sprintf("%-*s", widths[i], h) + } + fmt.Println(strings.Join(parts, " ")) + // separator + seps := make([]string, len(headers)) + for i := range headers { + seps[i] = strings.Repeat("-", widths[i]) + } + fmt.Println(strings.Join(seps, " ")) + // rows + for _, row := range rows { + parts := make([]string, len(headers)) + for i := range headers { + cell := "" + if i < len(row) { + cell = row[i] + } + parts[i] = fmt.Sprintf("%-*s", widths[i], cell) + } + fmt.Println(strings.Join(parts, " ")) + } +} + +// Error prints an error message to stderr and exits. +func Error(msg string) { + fmt.Fprintln(os.Stderr, msg) + os.Exit(1) +} + +// Errorf prints a formatted error message to stderr and exits. +func Errorf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/internal/passmgr/.gitkeep b/internal/passmgr/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/passmgr/doc.go b/internal/passmgr/doc.go deleted file mode 100644 index 89ef8f8..0000000 --- a/internal/passmgr/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package passmgr - -// Package passmgr will integrate with pass_mgr when available. diff --git a/internal/passmgr/passmgr.go b/internal/passmgr/passmgr.go new file mode 100644 index 0000000..df1c6eb --- /dev/null +++ b/internal/passmgr/passmgr.go @@ -0,0 +1,60 @@ +// Package passmgr wraps calls to the pass_mgr binary for secret resolution. +package passmgr + +import ( + "fmt" + "os/exec" + "strings" +) + +// GetSecret calls: pass_mgr get-secret [--public] --key +func GetSecret(key string, public bool) (string, error) { + args := []string{"get-secret"} + if public { + args = append(args, "--public") + } + args = append(args, "--key", key) + out, err := exec.Command("pass_mgr", args...).Output() + if err != nil { + return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err) + } + return strings.TrimSpace(string(out)), nil +} + +// SetSecret calls: pass_mgr set [--public] --key --secret +func SetSecret(key, secret string, public bool) error { + args := []string{"set"} + if public { + args = append(args, "--public") + } + args = append(args, "--key", key, "--secret", secret) + if err := exec.Command("pass_mgr", args...).Run(); err != nil { + return fmt.Errorf("pass_mgr set --key %s failed: %w", key, err) + } + return nil +} + +// GeneratePassword calls: pass_mgr generate --key --username +func GeneratePassword(key, username string) (string, error) { + args := []string{"generate", "--key", key, "--username", username} + out, err := exec.Command("pass_mgr", args...).Output() + if err != nil { + return "", fmt.Errorf("pass_mgr generate failed: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// GetToken retrieves the normal hf-token via pass_mgr. +func GetToken() (string, error) { + return GetSecret("hf-token", false) +} + +// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr. +func GetAccountManagerToken() (string, error) { + return GetSecret("hf-acc-mgr-token", true) +} + +// SetAccountManagerToken stores the acc-mgr-token as a public secret. +func SetAccountManagerToken(token string) error { + return SetSecret("hf-acc-mgr-token", token, true) +} -- 2.49.1 From f18eb366ebf8c611ad611874e2f7740ffab77a38 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 14:05:56 +0000 Subject: [PATCH 03/15] Implement group and leaf help stubs --- README.md | 2 +- cmd/hf/main.go | 243 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 220 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 8ceafdf..787420f 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Implemented: - Go module and binary entrypoint - Config file resolution relative to binary directory - Runtime mode detection (`pass_mgr` present/absent) -- Help and help-brief rendering system +- Top-level and group/leaf help rendering system (`--help` / `--help-brief` / `not permitted` stubs) - HTTP client wrapper - Output formatting (human-readable + `--json`) - `hf version`, `hf health`, `hf config` diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 92ce0f4..3f25c50 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -27,12 +27,16 @@ func main() { case "--help-brief": fmt.Print(help.RenderTopHelpBrief(commands.Version, topGroups())) case "version": - commands.RunVersion() + handleLeafOrRun("version", args[1:], commands.RunVersion) case "health": - commands.RunHealth() + handleLeafOrRun("health", args[1:], commands.RunHealth) case "config": - runConfig(args[1:]) + handleConfig(args[1:]) default: + if group, ok := findGroup(args[0]); ok { + handleGroup(group, args[1:]) + return + } fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) fmt.Fprintf(os.Stderr, "Run 'hf --help' for usage.\n") os.Exit(1) @@ -53,7 +57,22 @@ func parseGlobalFlags(args []string) []string { return remaining } -func runConfig(args []string) { +func handleLeafOrRun(name string, args []string, run func()) { + if isHelpFlagOnly(args) { + fmt.Printf("hf %s\n", name) + return + } + if len(args) > 0 { + output.Errorf("unknown arguments for %s: %v", name, args) + } + run() +} + +func handleConfig(args []string) { + if isHelpFlagOnly(args) { + runConfigHelp() + return + } if len(args) == 0 { commands.RunConfigShow() return @@ -72,22 +91,77 @@ func runConfig(args []string) { } commands.RunConfigAccMgrToken(args[i+1]) return - case "--help", "-h": - fmt.Println("hf config - View and manage CLI configuration") - fmt.Println() - fmt.Println("Usage:") - fmt.Println(" hf config Show current config") - fmt.Println(" hf config --url Set HarborForge API URL") - if !mode.IsPaddedCell() { - fmt.Println(" hf config --acc-mgr-token Set account-manager token") - } - return default: output.Errorf("unknown config flag: %s", args[i]) } } } +func runConfigHelp() { + fmt.Println("hf config - View and manage CLI configuration") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" hf config Show current config") + fmt.Println(" hf config --url Set HarborForge API URL") + if !mode.IsPaddedCell() { + fmt.Println(" hf config --acc-mgr-token Set account-manager token") + } +} + +func handleGroup(group help.Group, args []string) { + if len(args) == 0 || isHelpFlagOnly(args) { + fmt.Print(help.RenderGroupHelp(group.Name, group.SubCommands)) + return + } + if len(args) == 1 && args[0] == "--help-brief" { + fmt.Print(help.RenderGroupHelpBrief(group.Name, group.SubCommands)) + return + } + + sub, ok := findSubCommand(group, args[0]) + if !ok { + output.Errorf("unknown %s subcommand: %s", group.Name, args[0]) + } + + if len(args) > 1 && isHelpFlagOnly(args[1:]) { + if !sub.Permitted { + fmt.Println(help.RenderNotPermitted(group.Name, sub.Name)) + return + } + fmt.Printf("hf %s %s\n", group.Name, sub.Name) + return + } + + if !sub.Permitted { + fmt.Println(help.RenderNotPermitted(group.Name, sub.Name)) + return + } + + output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name) +} + +func isHelpFlagOnly(args []string) bool { + return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") +} + +func findGroup(name string) (help.Group, bool) { + for _, group := range topGroups() { + if group.Name == name { + return group, true + } + } + return help.Group{}, false +} + +func findSubCommand(group help.Group, name string) (help.Command, bool) { + for _, cmd := range group.SubCommands { + if cmd.Name == name { + return cmd, true + } + } + return help.Command{}, false +} + // topGroups returns the full command tree for help rendering. // TODO: permission awareness will be added when auth introspection is available. func topGroups() []help.Group { @@ -95,15 +169,136 @@ func topGroups() []help.Group { {Name: "version", Description: "Show CLI version", Permitted: true}, {Name: "health", Description: "Check API health", Permitted: true}, {Name: "config", Description: "View and manage CLI configuration", Permitted: true}, - {Name: "user", Description: "Manage users", Permitted: true}, - {Name: "role", Description: "Manage roles and permissions", Permitted: true}, - {Name: "permission", Description: "List permissions", Permitted: true}, - {Name: "project", Description: "Manage projects", Permitted: true}, - {Name: "milestone", Description: "Manage milestones", Permitted: true}, - {Name: "task", Description: "Manage tasks", Permitted: true}, - {Name: "meeting", Description: "Manage meetings", Permitted: true}, - {Name: "support", Description: "Manage support tickets", Permitted: true}, - {Name: "propose", Description: "Manage proposals", Permitted: true}, - {Name: "monitor", Description: "Monitor servers and API keys", Permitted: true}, + { + Name: "user", + Description: "Manage users", + Permitted: true, + SubCommands: []help.Command{ + {Name: "create", Description: "Create a user account (uses account-manager token flow)", Permitted: false}, + {Name: "list", Description: "List users", Permitted: false}, + {Name: "get", Description: "Show a user by username", Permitted: false}, + {Name: "update", Description: "Update a user", Permitted: false}, + {Name: "activate", Description: "Activate a user", Permitted: false}, + {Name: "deactivate", Description: "Deactivate a user", Permitted: false}, + {Name: "delete", Description: "Delete a user", Permitted: false}, + }, + }, + { + Name: "role", + Description: "Manage roles and permissions", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List roles", Permitted: false}, + {Name: "get", Description: "Show a role by name", Permitted: false}, + {Name: "create", Description: "Create a role", Permitted: false}, + {Name: "update", Description: "Update a role", Permitted: false}, + {Name: "delete", Description: "Delete a role", Permitted: false}, + {Name: "set-permissions", Description: "Replace role permissions", Permitted: false}, + {Name: "add-permissions", Description: "Add permissions to a role", Permitted: false}, + {Name: "remove-permissions", Description: "Remove permissions from a role", Permitted: false}, + }, + }, + { + Name: "permission", + Description: "List permissions", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List permissions", Permitted: false}, + }, + }, + { + Name: "project", + Description: "Manage projects", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List projects", Permitted: false}, + {Name: "get", Description: "Show a project by code", Permitted: false}, + {Name: "create", Description: "Create a project", Permitted: false}, + {Name: "update", Description: "Update a project", Permitted: false}, + {Name: "delete", Description: "Delete a project", Permitted: false}, + {Name: "members", Description: "List project members", Permitted: false}, + {Name: "add-member", Description: "Add a project member", Permitted: false}, + {Name: "remove-member", Description: "Remove a project member", Permitted: false}, + }, + }, + { + Name: "milestone", + Description: "Manage milestones", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List milestones", Permitted: false}, + {Name: "get", Description: "Show a milestone by code", Permitted: false}, + {Name: "create", Description: "Create a milestone", Permitted: false}, + {Name: "update", Description: "Update a milestone", Permitted: false}, + {Name: "delete", Description: "Delete a milestone", Permitted: false}, + {Name: "progress", Description: "Show milestone progress", Permitted: false}, + }, + }, + { + Name: "task", + Description: "Manage tasks", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List tasks", Permitted: false}, + {Name: "get", Description: "Show a task by code", Permitted: false}, + {Name: "create", Description: "Create a task", Permitted: false}, + {Name: "update", Description: "Update a task", Permitted: false}, + {Name: "transition", Description: "Transition a task to a new status", Permitted: false}, + {Name: "take", Description: "Assign a task to the current user", Permitted: false}, + {Name: "delete", Description: "Delete a task", Permitted: false}, + {Name: "search", Description: "Search tasks", Permitted: false}, + }, + }, + { + Name: "meeting", + Description: "Manage meetings", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List meetings", Permitted: false}, + {Name: "get", Description: "Show a meeting by code", Permitted: false}, + {Name: "create", Description: "Create a meeting", Permitted: false}, + {Name: "update", Description: "Update a meeting", Permitted: false}, + {Name: "attend", Description: "Attend a meeting", Permitted: false}, + {Name: "delete", Description: "Delete a meeting", Permitted: false}, + }, + }, + { + Name: "support", + Description: "Manage support tickets", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List support tickets", Permitted: false}, + {Name: "get", Description: "Show a support ticket by code", Permitted: false}, + {Name: "create", Description: "Create a support ticket", Permitted: false}, + {Name: "update", Description: "Update a support ticket", Permitted: false}, + {Name: "take", Description: "Assign a support ticket to the current user", Permitted: false}, + {Name: "transition", Description: "Transition a support ticket to a new status", Permitted: false}, + {Name: "delete", Description: "Delete a support ticket", Permitted: false}, + }, + }, + { + Name: "propose", + Description: "Manage proposals", + Permitted: true, + SubCommands: []help.Command{ + {Name: "list", Description: "List proposals", Permitted: false}, + {Name: "get", Description: "Show a proposal by code", Permitted: false}, + {Name: "create", Description: "Create a proposal", Permitted: false}, + {Name: "update", Description: "Update a proposal", Permitted: false}, + {Name: "accept", Description: "Accept a proposal", Permitted: false}, + {Name: "reject", Description: "Reject a proposal", Permitted: false}, + {Name: "reopen", Description: "Reopen a proposal", Permitted: false}, + }, + }, + { + Name: "monitor", + Description: "Monitor servers and API keys", + Permitted: true, + SubCommands: []help.Command{ + {Name: "overview", Description: "Show monitor overview", Permitted: false}, + {Name: "server", Description: "Manage monitor servers", Permitted: false}, + {Name: "api-key", Description: "Manage monitor API keys", Permitted: false}, + }, + }, } } -- 2.49.1 From 25114aa17ceb4b10b2bc0b8a52fbfbb208c14172 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 14:22:06 +0000 Subject: [PATCH 04/15] feat: implement user commands (list, get, create, update, activate, deactivate, delete) - Add internal/commands/user.go with full user CRUD implementation - Wire user subcommands in main.go dispatch - Mark user subcommands as Permitted: true - Support both padded-cell and manual mode for all user commands - user create uses account-manager token flow per plan --- cmd/hf/main.go | 109 ++++++++++++- internal/commands/user.go | 331 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 internal/commands/user.go diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 3f25c50..7f20ce2 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -137,9 +137,104 @@ func handleGroup(group help.Group, args []string) { return } + // Dispatch implemented commands + remaining := args[1:] + switch group.Name { + case "user": + handleUserCommand(sub.Name, remaining) + return + } + output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name) } +func handleUserCommand(subCmd string, args []string) { + // Extract --token and --acc-mgr-token flags from args + tokenFlag := "" + accMgrTokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + case "--acc-mgr-token": + if i+1 < len(args) { + i++ + accMgrTokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunUserList(tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf user get ") + } + commands.RunUserGet(filtered[0], tokenFlag) + case "create": + username, password, email, fullName := "", "", "", "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--user": + if i+1 < len(filtered) { + i++ + username = filtered[i] + } + case "--pass": + if i+1 < len(filtered) { + i++ + password = filtered[i] + } + case "--email": + if i+1 < len(filtered) { + i++ + email = filtered[i] + } + case "--full-name": + if i+1 < len(filtered) { + i++ + fullName = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + if username == "" { + output.Error("usage: hf user create --user ") + } + commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf user update [--email ...] [--full-name ...] [--pass ...] [--active ...]") + } + commands.RunUserUpdate(filtered[0], filtered[1:], tokenFlag) + case "activate": + if len(filtered) < 1 { + output.Error("usage: hf user activate ") + } + commands.RunUserActivate(filtered[0], tokenFlag) + case "deactivate": + if len(filtered) < 1 { + output.Error("usage: hf user deactivate ") + } + commands.RunUserDeactivate(filtered[0], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf user delete ") + } + commands.RunUserDelete(filtered[0], tokenFlag) + default: + output.Errorf("hf user %s is not implemented yet", subCmd) + } +} + func isHelpFlagOnly(args []string) bool { return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") } @@ -174,13 +269,13 @@ func topGroups() []help.Group { Description: "Manage users", Permitted: true, SubCommands: []help.Command{ - {Name: "create", Description: "Create a user account (uses account-manager token flow)", Permitted: false}, - {Name: "list", Description: "List users", Permitted: false}, - {Name: "get", Description: "Show a user by username", Permitted: false}, - {Name: "update", Description: "Update a user", Permitted: false}, - {Name: "activate", Description: "Activate a user", Permitted: false}, - {Name: "deactivate", Description: "Deactivate a user", Permitted: false}, - {Name: "delete", Description: "Delete a user", Permitted: false}, + {Name: "create", Description: "Create a user account (uses account-manager token flow)", Permitted: true}, + {Name: "list", Description: "List users", Permitted: true}, + {Name: "get", Description: "Show a user by username", Permitted: true}, + {Name: "update", Description: "Update a user", Permitted: true}, + {Name: "activate", Description: "Activate a user", Permitted: true}, + {Name: "deactivate", Description: "Deactivate a user", Permitted: true}, + {Name: "delete", Description: "Delete a user", Permitted: true}, }, }, { diff --git a/internal/commands/user.go b/internal/commands/user.go new file mode 100644 index 0000000..445b1ac --- /dev/null +++ b/internal/commands/user.go @@ -0,0 +1,331 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" +) + +// userResponse matches the backend UserResponse schema. +type userResponse struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FullName *string `json:"full_name"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` + RoleID *int `json:"role_id"` + RoleName *string `json:"role_name"` + CreatedAt string `json:"created_at"` +} + +// RunUserList implements `hf user list`. +func RunUserList(tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/users") + if err != nil { + output.Errorf("failed to list users: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var users []userResponse + if err := json.Unmarshal(data, &users); err != nil { + output.Errorf("cannot parse user list: %v", err) + } + + headers := []string{"USERNAME", "EMAIL", "FULL NAME", "ROLE", "ACTIVE", "ADMIN"} + var rows [][]string + for _, u := range users { + fullName := "" + if u.FullName != nil { + fullName = *u.FullName + } + roleName := "" + if u.RoleName != nil { + roleName = *u.RoleName + } + active := "yes" + if !u.IsActive { + active = "no" + } + admin := "" + if u.IsAdmin { + admin = "yes" + } + rows = append(rows, []string{u.Username, u.Email, fullName, roleName, active, admin}) + } + output.PrintTable(headers, rows) +} + +// RunUserGet implements `hf user get `. +func RunUserGet(username, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/users/" + username) + if err != nil { + output.Errorf("failed to get user: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var u userResponse + if err := json.Unmarshal(data, &u); err != nil { + output.Errorf("cannot parse user: %v", err) + } + + fullName := "" + if u.FullName != nil { + fullName = *u.FullName + } + roleName := "" + if u.RoleName != nil { + roleName = *u.RoleName + } + active := "yes" + if !u.IsActive { + active = "no" + } + admin := "" + if u.IsAdmin { + admin = "yes" + } + output.PrintKeyValue( + "username", u.Username, + "email", u.Email, + "full-name", fullName, + "role", roleName, + "active", active, + "admin", admin, + "created", u.CreatedAt, + ) +} + +// userCreatePayload is the JSON body for POST /users. +type userCreatePayload struct { + Username string `json:"username"` + Email string `json:"email"` + FullName *string `json:"full_name,omitempty"` + Password *string `json:"password,omitempty"` +} + +// RunUserCreate implements `hf user create`. +func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) { + // Resolve account-manager token + var accMgrToken string + if mode.IsPaddedCell() { + if accMgrTokenFlag != "" { + output.Error("padded-cell installed, --acc-mgr-token flag disabled, use command directly") + } + tok, err := passmgr.GetAccountManagerToken() + if err != nil { + output.Error("--acc-mgr-token required or execute with pcexec") + } + accMgrToken = tok + } else { + if accMgrTokenFlag == "" { + output.Error("--acc-mgr-token required or execute with pcexec") + } + accMgrToken = accMgrTokenFlag + } + + // Resolve password + if password == "" && mode.IsPaddedCell() { + pw, err := passmgr.GeneratePassword("hf", username) + if err != nil { + output.Error("--pass required or execute with pcexec") + } + password = pw + } + if password == "" && !mode.IsPaddedCell() { + output.Error("--pass required or execute with pcexec") + } + + // Resolve email (default to username@harborforge.local if not provided) + if email == "" { + email = username + "@harborforge.local" + } + + payload := userCreatePayload{ + Username: username, + Email: email, + Password: &password, + } + if fullName != "" { + payload.FullName = &fullName + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, accMgrToken) + data, err := c.Post("/users", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create user: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var u userResponse + if err := json.Unmarshal(data, &u); err != nil { + output.Errorf("cannot parse response: %v", err) + } + fmt.Printf("user created: %s\n", u.Username) +} + +// RunUserUpdate implements `hf user update `. +func RunUserUpdate(username string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--email": + if i+1 >= len(args) { + output.Error("--email requires a value") + } + i++ + payload["email"] = args[i] + case "--full-name": + if i+1 >= len(args) { + output.Error("--full-name requires a value") + } + i++ + payload["full_name"] = args[i] + case "--pass": + if i+1 >= len(args) { + output.Error("--pass requires a value") + } + i++ + payload["password"] = args[i] + case "--active": + if i+1 >= len(args) { + output.Error("--active requires true or false") + } + i++ + payload["is_active"] = strings.ToLower(args[i]) == "true" + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Patch("/users/"+username, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update user: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + fmt.Printf("user updated: %s\n", username) +} + +// RunUserActivate implements `hf user activate `. +func RunUserActivate(username, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + body, _ := json.Marshal(map[string]interface{}{"is_active": true}) + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/users/"+username, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to activate user: %v", err) + } + fmt.Printf("user activated: %s\n", username) +} + +// RunUserDeactivate implements `hf user deactivate `. +func RunUserDeactivate(username, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + body, _ := json.Marshal(map[string]interface{}{"is_active": false}) + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/users/"+username, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to deactivate user: %v", err) + } + fmt.Printf("user deactivated: %s\n", username) +} + +// RunUserDelete implements `hf user delete `. +func RunUserDelete(username, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/users/" + username) + if err != nil { + output.Errorf("failed to delete user: %v", err) + } + fmt.Printf("user deleted: %s\n", username) +} -- 2.49.1 From 69287d5a49cce12f23391b3dd1960c7ff7aa89fc Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 14:37:42 +0000 Subject: [PATCH 05/15] Add permission-aware help surface --- README.md | 3 +- cmd/hf/main.go | 149 +-------------------------- internal/help/help.go | 2 +- internal/help/surface.go | 211 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 147 deletions(-) create mode 100644 internal/help/surface.go diff --git a/README.md b/README.md index 787420f..ea7c864 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Implemented: - Config file resolution relative to binary directory - Runtime mode detection (`pass_mgr` present/absent) - Top-level and group/leaf help rendering system (`--help` / `--help-brief` / `not permitted` stubs) +- Permission-aware command visibility via `/auth/me/permissions` when a token is available - HTTP client wrapper - Output formatting (human-readable + `--json`) - `hf version`, `hf health`, `hf config` @@ -72,4 +73,4 @@ Implemented: Planned: - User, role, project, task, milestone, meeting, support, propose, monitor commands -- Permission-aware help rendering +- Rich per-command help/usage text beyond the current stub renderer diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 7f20ce2..faf2796 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -17,15 +17,15 @@ func main() { args = parseGlobalFlags(args) if len(args) == 0 { - fmt.Print(help.RenderTopHelp(commands.Version, topGroups())) + fmt.Print(help.RenderTopHelp(commands.Version, help.CommandSurface())) return } switch args[0] { case "--help", "-h": - fmt.Print(help.RenderTopHelp(commands.Version, topGroups())) + fmt.Print(help.RenderTopHelp(commands.Version, help.CommandSurface())) case "--help-brief": - fmt.Print(help.RenderTopHelpBrief(commands.Version, topGroups())) + fmt.Print(help.RenderTopHelpBrief(commands.Version, help.CommandSurface())) case "version": handleLeafOrRun("version", args[1:], commands.RunVersion) case "health": @@ -240,7 +240,7 @@ func isHelpFlagOnly(args []string) bool { } func findGroup(name string) (help.Group, bool) { - for _, group := range topGroups() { + for _, group := range help.CommandSurface() { if group.Name == name { return group, true } @@ -256,144 +256,3 @@ func findSubCommand(group help.Group, name string) (help.Command, bool) { } return help.Command{}, false } - -// topGroups returns the full command tree for help rendering. -// TODO: permission awareness will be added when auth introspection is available. -func topGroups() []help.Group { - return []help.Group{ - {Name: "version", Description: "Show CLI version", Permitted: true}, - {Name: "health", Description: "Check API health", Permitted: true}, - {Name: "config", Description: "View and manage CLI configuration", Permitted: true}, - { - Name: "user", - Description: "Manage users", - Permitted: true, - SubCommands: []help.Command{ - {Name: "create", Description: "Create a user account (uses account-manager token flow)", Permitted: true}, - {Name: "list", Description: "List users", Permitted: true}, - {Name: "get", Description: "Show a user by username", Permitted: true}, - {Name: "update", Description: "Update a user", Permitted: true}, - {Name: "activate", Description: "Activate a user", Permitted: true}, - {Name: "deactivate", Description: "Deactivate a user", Permitted: true}, - {Name: "delete", Description: "Delete a user", Permitted: true}, - }, - }, - { - Name: "role", - Description: "Manage roles and permissions", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List roles", Permitted: false}, - {Name: "get", Description: "Show a role by name", Permitted: false}, - {Name: "create", Description: "Create a role", Permitted: false}, - {Name: "update", Description: "Update a role", Permitted: false}, - {Name: "delete", Description: "Delete a role", Permitted: false}, - {Name: "set-permissions", Description: "Replace role permissions", Permitted: false}, - {Name: "add-permissions", Description: "Add permissions to a role", Permitted: false}, - {Name: "remove-permissions", Description: "Remove permissions from a role", Permitted: false}, - }, - }, - { - Name: "permission", - Description: "List permissions", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List permissions", Permitted: false}, - }, - }, - { - Name: "project", - Description: "Manage projects", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List projects", Permitted: false}, - {Name: "get", Description: "Show a project by code", Permitted: false}, - {Name: "create", Description: "Create a project", Permitted: false}, - {Name: "update", Description: "Update a project", Permitted: false}, - {Name: "delete", Description: "Delete a project", Permitted: false}, - {Name: "members", Description: "List project members", Permitted: false}, - {Name: "add-member", Description: "Add a project member", Permitted: false}, - {Name: "remove-member", Description: "Remove a project member", Permitted: false}, - }, - }, - { - Name: "milestone", - Description: "Manage milestones", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List milestones", Permitted: false}, - {Name: "get", Description: "Show a milestone by code", Permitted: false}, - {Name: "create", Description: "Create a milestone", Permitted: false}, - {Name: "update", Description: "Update a milestone", Permitted: false}, - {Name: "delete", Description: "Delete a milestone", Permitted: false}, - {Name: "progress", Description: "Show milestone progress", Permitted: false}, - }, - }, - { - Name: "task", - Description: "Manage tasks", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List tasks", Permitted: false}, - {Name: "get", Description: "Show a task by code", Permitted: false}, - {Name: "create", Description: "Create a task", Permitted: false}, - {Name: "update", Description: "Update a task", Permitted: false}, - {Name: "transition", Description: "Transition a task to a new status", Permitted: false}, - {Name: "take", Description: "Assign a task to the current user", Permitted: false}, - {Name: "delete", Description: "Delete a task", Permitted: false}, - {Name: "search", Description: "Search tasks", Permitted: false}, - }, - }, - { - Name: "meeting", - Description: "Manage meetings", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List meetings", Permitted: false}, - {Name: "get", Description: "Show a meeting by code", Permitted: false}, - {Name: "create", Description: "Create a meeting", Permitted: false}, - {Name: "update", Description: "Update a meeting", Permitted: false}, - {Name: "attend", Description: "Attend a meeting", Permitted: false}, - {Name: "delete", Description: "Delete a meeting", Permitted: false}, - }, - }, - { - Name: "support", - Description: "Manage support tickets", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List support tickets", Permitted: false}, - {Name: "get", Description: "Show a support ticket by code", Permitted: false}, - {Name: "create", Description: "Create a support ticket", Permitted: false}, - {Name: "update", Description: "Update a support ticket", Permitted: false}, - {Name: "take", Description: "Assign a support ticket to the current user", Permitted: false}, - {Name: "transition", Description: "Transition a support ticket to a new status", Permitted: false}, - {Name: "delete", Description: "Delete a support ticket", Permitted: false}, - }, - }, - { - Name: "propose", - Description: "Manage proposals", - Permitted: true, - SubCommands: []help.Command{ - {Name: "list", Description: "List proposals", Permitted: false}, - {Name: "get", Description: "Show a proposal by code", Permitted: false}, - {Name: "create", Description: "Create a proposal", Permitted: false}, - {Name: "update", Description: "Update a proposal", Permitted: false}, - {Name: "accept", Description: "Accept a proposal", Permitted: false}, - {Name: "reject", Description: "Reject a proposal", Permitted: false}, - {Name: "reopen", Description: "Reopen a proposal", Permitted: false}, - }, - }, - { - Name: "monitor", - Description: "Monitor servers and API keys", - Permitted: true, - SubCommands: []help.Command{ - {Name: "overview", Description: "Show monitor overview", Permitted: false}, - {Name: "server", Description: "Manage monitor servers", Permitted: false}, - {Name: "api-key", Description: "Manage monitor API keys", Permitted: false}, - }, - }, - } -} diff --git a/internal/help/help.go b/internal/help/help.go index 04788ee..a545cae 100644 --- a/internal/help/help.go +++ b/internal/help/help.go @@ -10,7 +10,7 @@ import ( type Command struct { Name string Description string - Permitted bool // whether the current user can execute this + Permitted bool // whether the current user can execute this SubCommands []Command } diff --git a/internal/help/surface.go b/internal/help/surface.go new file mode 100644 index 0000000..89bf7ec --- /dev/null +++ b/internal/help/surface.go @@ -0,0 +1,211 @@ +package help + +import ( + "encoding/json" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" +) + +type permissionState struct { + Known bool + Permissions map[string]struct{} +} + +type permissionIntrospectionResponse struct { + Username string `json:"username"` + RoleName *string `json:"role_name"` + IsAdmin bool `json:"is_admin"` + Permissions []string `json:"permissions"` +} + +func CommandSurface() []Group { + perms := detectPermissionState() + + groups := []Group{ + {Name: "version", Description: "Show CLI version", Permitted: true}, + {Name: "health", Description: "Check API health", Permitted: true}, + {Name: "config", Description: "View and manage CLI configuration", Permitted: true}, + { + Name: "user", + Description: "Manage users", + SubCommands: []Command{ + {Name: "create", Description: "Create a user account (uses account-manager token flow)", Permitted: true}, + {Name: "list", Description: "List users", Permitted: has(perms, "user.manage")}, + {Name: "get", Description: "Show a user by username", Permitted: has(perms, "user.manage")}, + {Name: "update", Description: "Update a user", Permitted: has(perms, "user.manage")}, + {Name: "activate", Description: "Activate a user", Permitted: has(perms, "user.manage")}, + {Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")}, + {Name: "delete", Description: "Delete a user", Permitted: has(perms, "user.manage")}, + }, + }, + { + Name: "role", + Description: "Manage roles and permissions", + SubCommands: []Command{ + {Name: "list", Description: "List roles", Permitted: has(perms, "role.manage")}, + {Name: "get", Description: "Show a role by name", Permitted: has(perms, "role.manage")}, + {Name: "create", Description: "Create a role", Permitted: has(perms, "role.manage")}, + {Name: "update", Description: "Update a role", Permitted: has(perms, "role.manage")}, + {Name: "delete", Description: "Delete a role", Permitted: has(perms, "role.manage")}, + {Name: "set-permissions", Description: "Replace role permissions", Permitted: has(perms, "role.manage")}, + {Name: "add-permissions", Description: "Add permissions to a role", Permitted: has(perms, "role.manage")}, + {Name: "remove-permissions", Description: "Remove permissions from a role", Permitted: has(perms, "role.manage")}, + }, + }, + { + Name: "permission", + Description: "List permissions", + SubCommands: []Command{{Name: "list", Description: "List permissions", Permitted: has(perms, "role.manage")}}, + }, + { + Name: "project", + Description: "Manage projects", + SubCommands: []Command{ + {Name: "list", Description: "List projects", Permitted: has(perms, "project.read")}, + {Name: "get", Description: "Show a project by code", Permitted: has(perms, "project.read")}, + {Name: "create", Description: "Create a project", Permitted: has(perms, "project.write")}, + {Name: "update", Description: "Update a project", Permitted: has(perms, "project.write")}, + {Name: "delete", Description: "Delete a project", Permitted: has(perms, "project.delete")}, + {Name: "members", Description: "List project members", Permitted: has(perms, "project.read")}, + {Name: "add-member", Description: "Add a project member", Permitted: has(perms, "project.manage_members")}, + {Name: "remove-member", Description: "Remove a project member", Permitted: has(perms, "project.manage_members")}, + }, + }, + { + Name: "milestone", + Description: "Manage milestones", + SubCommands: []Command{ + {Name: "list", Description: "List milestones", Permitted: has(perms, "milestone.read")}, + {Name: "get", Description: "Show a milestone by code", Permitted: has(perms, "milestone.read")}, + {Name: "create", Description: "Create a milestone", Permitted: has(perms, "milestone.create")}, + {Name: "update", Description: "Update a milestone", Permitted: has(perms, "milestone.write")}, + {Name: "delete", Description: "Delete a milestone", Permitted: has(perms, "milestone.delete")}, + {Name: "progress", Description: "Show milestone progress", Permitted: has(perms, "milestone.read")}, + }, + }, + { + Name: "task", + Description: "Manage tasks", + SubCommands: []Command{ + {Name: "list", Description: "List tasks", Permitted: has(perms, "task.read")}, + {Name: "get", Description: "Show a task by code", Permitted: has(perms, "task.read")}, + {Name: "create", Description: "Create a task", Permitted: has(perms, "task.create")}, + {Name: "update", Description: "Update a task", Permitted: has(perms, "task.write")}, + {Name: "transition", Description: "Transition a task to a new status", Permitted: has(perms, "task.write")}, + {Name: "take", Description: "Assign a task to the current user", Permitted: has(perms, "task.write")}, + {Name: "delete", Description: "Delete a task", Permitted: has(perms, "task.delete")}, + {Name: "search", Description: "Search tasks", Permitted: has(perms, "task.read")}, + }, + }, + { + Name: "meeting", + Description: "Manage meetings", + SubCommands: []Command{ + {Name: "list", Description: "List meetings", Permitted: has(perms, "task.read")}, + {Name: "get", Description: "Show a meeting by code", Permitted: has(perms, "task.read")}, + {Name: "create", Description: "Create a meeting", Permitted: has(perms, "task.create")}, + {Name: "update", Description: "Update a meeting", Permitted: has(perms, "task.write")}, + {Name: "attend", Description: "Attend a meeting", Permitted: has(perms, "task.write")}, + {Name: "delete", Description: "Delete a meeting", Permitted: has(perms, "task.delete")}, + }, + }, + { + Name: "support", + Description: "Manage support tickets", + SubCommands: []Command{ + {Name: "list", Description: "List support tickets", Permitted: has(perms, "task.read")}, + {Name: "get", Description: "Show a support ticket by code", Permitted: has(perms, "task.read")}, + {Name: "create", Description: "Create a support ticket", Permitted: has(perms, "task.create")}, + {Name: "update", Description: "Update a support ticket", Permitted: has(perms, "task.write")}, + {Name: "take", Description: "Assign a support ticket to the current user", Permitted: has(perms, "task.write")}, + {Name: "transition", Description: "Transition a support ticket to a new status", Permitted: has(perms, "task.write")}, + {Name: "delete", Description: "Delete a support ticket", Permitted: has(perms, "task.delete")}, + }, + }, + { + Name: "propose", + Description: "Manage proposals", + SubCommands: []Command{ + {Name: "list", Description: "List proposals", Permitted: has(perms, "project.read")}, + {Name: "get", Description: "Show a proposal by code", Permitted: has(perms, "project.read")}, + {Name: "create", Description: "Create a proposal", Permitted: has(perms, "task.create")}, + {Name: "update", Description: "Update a proposal", Permitted: has(perms, "task.write")}, + {Name: "accept", Description: "Accept a proposal", Permitted: has(perms, "propose.accept")}, + {Name: "reject", Description: "Reject a proposal", Permitted: has(perms, "propose.reject")}, + {Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")}, + }, + }, + { + Name: "monitor", + Description: "Monitor servers and API keys", + SubCommands: []Command{ + {Name: "overview", Description: "Show monitor overview", Permitted: has(perms, "monitor.read")}, + {Name: "server", Description: "Manage monitor servers", Permitted: has(perms, "monitor.manage") || has(perms, "monitor.read")}, + {Name: "api-key", Description: "Manage monitor API keys", Permitted: has(perms, "monitor.manage")}, + }, + }, + } + + for i := range groups { + groups[i].Permitted = groupPermitted(groups[i]) + } + return groups +} + +func detectPermissionState() permissionState { + if mode.IsPaddedCell() { + token, err := passmgr.GetToken() + if err != nil || token == "" { + return permissionState{Known: false, Permissions: map[string]struct{}{}} + } + return loadPermissionState(token) + } + return permissionState{Known: false, Permissions: map[string]struct{}{}} +} + +func loadPermissionState(token string) permissionState { + cfg, err := config.Load() + if err != nil || cfg.BaseURL == "" { + return permissionState{Known: false, Permissions: map[string]struct{}{}} + } + + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/auth/me/permissions") + if err != nil { + return permissionState{Known: false, Permissions: map[string]struct{}{}} + } + + var resp permissionIntrospectionResponse + if err := json.Unmarshal(data, &resp); err != nil { + return permissionState{Known: false, Permissions: map[string]struct{}{}} + } + + perms := make(map[string]struct{}, len(resp.Permissions)) + for _, perm := range resp.Permissions { + perms[perm] = struct{}{} + } + return permissionState{Known: true, Permissions: perms} +} + +func has(state permissionState, perm string) bool { + if !state.Known { + return false + } + _, ok := state.Permissions[perm] + return ok +} + +func groupPermitted(group Group) bool { + if len(group.SubCommands) == 0 { + return group.Permitted + } + for _, cmd := range group.SubCommands { + if cmd.Permitted { + return true + } + } + return false +} -- 2.49.1 From 57af1512d1701d74bbc91055b02b0bb95ba46a68 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 14:50:43 +0000 Subject: [PATCH 06/15] feat: implement role, permission, project, milestone, and task command groups - Add role commands: list, get, create, update, delete, set/add/remove-permissions - Add permission list command - Add project commands: list, get, create, update, delete, members, add/remove-member - Add milestone commands: list, get, create, update, delete, progress - Add task commands: list, get, create, update, transition, take, delete, search - Wire all new command groups into main.go dispatcher - All commands support --json output mode and --token manual auth - Passes go build and go vet cleanly --- cmd/hf/main.go | 284 ++++++++++++++++++++ internal/commands/milestone.go | 342 +++++++++++++++++++++++ internal/commands/project.go | 413 ++++++++++++++++++++++++++++ internal/commands/role.go | 334 +++++++++++++++++++++++ internal/commands/task.go | 478 +++++++++++++++++++++++++++++++++ 5 files changed, 1851 insertions(+) create mode 100644 internal/commands/milestone.go create mode 100644 internal/commands/project.go create mode 100644 internal/commands/role.go create mode 100644 internal/commands/task.go diff --git a/cmd/hf/main.go b/cmd/hf/main.go index faf2796..8737869 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -143,6 +143,21 @@ func handleGroup(group help.Group, args []string) { case "user": handleUserCommand(sub.Name, remaining) return + case "role": + handleRoleCommand(sub.Name, remaining) + return + case "permission": + handlePermissionCommand(sub.Name, remaining) + return + case "project": + handleProjectCommand(sub.Name, remaining) + return + case "milestone": + handleMilestoneCommand(sub.Name, remaining) + return + case "task": + handleTaskCommand(sub.Name, remaining) + return } output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name) @@ -256,3 +271,272 @@ func findSubCommand(group help.Group, name string) (help.Command, bool) { } return help.Command{}, false } + +func handleRoleCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunRoleList(tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf role get ") + } + commands.RunRoleGet(filtered[0], tokenFlag) + case "create": + name, desc := "", "" + global := false + var remaining []string + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--name": + if i+1 < len(filtered) { + i++ + name = filtered[i] + } + case "--desc": + if i+1 < len(filtered) { + i++ + desc = filtered[i] + } + case "--global": + if i+1 < len(filtered) { + i++ + global = filtered[i] == "true" + } + default: + remaining = append(remaining, filtered[i]) + } + } + _ = remaining + if name == "" { + output.Error("usage: hf role create --name ") + } + commands.RunRoleCreate(name, desc, global, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf role update [--desc ...]") + } + commands.RunRoleUpdate(filtered[0], filtered[1:], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf role delete ") + } + commands.RunRoleDelete(filtered[0], tokenFlag) + case "set-permissions": + if len(filtered) < 1 { + output.Error("usage: hf role set-permissions --permission [...]") + } + roleName := filtered[0] + perms := extractPermissions(filtered[1:]) + commands.RunRoleSetPermissions(roleName, perms, tokenFlag) + case "add-permissions": + if len(filtered) < 1 { + output.Error("usage: hf role add-permissions --permission [...]") + } + roleName := filtered[0] + perms := extractPermissions(filtered[1:]) + commands.RunRoleAddPermissions(roleName, perms, tokenFlag) + case "remove-permissions": + if len(filtered) < 1 { + output.Error("usage: hf role remove-permissions --permission [...]") + } + roleName := filtered[0] + perms := extractPermissions(filtered[1:]) + commands.RunRoleRemovePermissions(roleName, perms, tokenFlag) + default: + output.Errorf("hf role %s is not implemented yet", subCmd) + } +} + +func extractPermissions(args []string) []string { + var perms []string + for i := 0; i < len(args); i++ { + if args[i] == "--permission" && i+1 < len(args) { + i++ + perms = append(perms, args[i]) + } + } + return perms +} + +func handlePermissionCommand(subCmd string, args []string) { + tokenFlag := "" + for i := 0; i < len(args); i++ { + if args[i] == "--token" && i+1 < len(args) { + i++ + tokenFlag = args[i] + } + } + + switch subCmd { + case "list": + commands.RunPermissionList(tokenFlag) + default: + output.Errorf("hf permission %s is not implemented yet", subCmd) + } +} + +func handleMilestoneCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunMilestoneList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf milestone get ") + } + commands.RunMilestoneGet(filtered[0], tokenFlag) + case "create": + commands.RunMilestoneCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf milestone update [--title ...] [--desc ...] [--status ...] [--due ...]") + } + commands.RunMilestoneUpdate(filtered[0], filtered[1:], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf milestone delete ") + } + commands.RunMilestoneDelete(filtered[0], tokenFlag) + case "progress": + if len(filtered) < 1 { + output.Error("usage: hf milestone progress ") + } + commands.RunMilestoneProgress(filtered[0], tokenFlag) + default: + output.Errorf("hf milestone %s is not implemented yet", subCmd) + } +} + +func handleTaskCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunTaskList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf task get ") + } + commands.RunTaskGet(filtered[0], tokenFlag) + case "create": + commands.RunTaskCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf task update [--title ...] [--desc ...] [--status ...] [--priority ...] [--assignee ...]") + } + commands.RunTaskUpdate(filtered[0], filtered[1:], tokenFlag) + case "transition": + if len(filtered) < 2 { + output.Error("usage: hf task transition ") + } + commands.RunTaskTransition(filtered[0], filtered[1], tokenFlag) + case "take": + if len(filtered) < 1 { + output.Error("usage: hf task take ") + } + commands.RunTaskTake(filtered[0], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf task delete ") + } + commands.RunTaskDelete(filtered[0], tokenFlag) + case "search": + commands.RunTaskSearch(filtered, tokenFlag) + default: + output.Errorf("hf task %s is not implemented yet", subCmd) + } +} + +func handleProjectCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunProjectList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf project get ") + } + commands.RunProjectGet(filtered[0], tokenFlag) + case "create": + commands.RunProjectCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf project update [--name ...] [--desc ...] [--repo ...]") + } + commands.RunProjectUpdate(filtered[0], filtered[1:], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf project delete ") + } + commands.RunProjectDelete(filtered[0], tokenFlag) + case "members": + if len(filtered) < 1 { + output.Error("usage: hf project members ") + } + commands.RunProjectMembers(filtered[0], tokenFlag) + case "add-member": + if len(filtered) < 1 { + output.Error("usage: hf project add-member --user --role ") + } + commands.RunProjectAddMember(filtered[0], filtered[1:], tokenFlag) + case "remove-member": + if len(filtered) < 1 { + output.Error("usage: hf project remove-member --user ") + } + commands.RunProjectRemoveMember(filtered[0], filtered[1:], tokenFlag) + default: + output.Errorf("hf project %s is not implemented yet", subCmd) + } +} diff --git a/internal/commands/milestone.go b/internal/commands/milestone.go new file mode 100644 index 0000000..dea241c --- /dev/null +++ b/internal/commands/milestone.go @@ -0,0 +1,342 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// milestoneResponse matches the backend MilestoneResponse schema. +type milestoneResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + DueDate *string `json:"due_date"` + ProjectCode string `json:"project_code"` + CreatedAt string `json:"created_at"` +} + +// milestoneProgressResponse matches the backend progress response. +type milestoneProgressResponse struct { + Code string `json:"code"` + Title string `json:"title"` + Status string `json:"status"` + TotalTasks int `json:"total_tasks"` + DoneTasks int `json:"done_tasks"` + Progress float64 `json:"progress"` +} + +// RunMilestoneList implements `hf milestone list --project `. +func RunMilestoneList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + query = appendQuery(query, "project", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/milestones" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list milestones: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var milestones []milestoneResponse + if err := json.Unmarshal(data, &milestones); err != nil { + output.Errorf("cannot parse milestone list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "DUE DATE", "PROJECT"} + var rows [][]string + for _, m := range milestones { + due := "" + if m.DueDate != nil { + due = *m.DueDate + } + rows = append(rows, []string{m.Code, m.Title, m.Status, due, m.ProjectCode}) + } + output.PrintTable(headers, rows) +} + +// RunMilestoneGet implements `hf milestone get `. +func RunMilestoneGet(milestoneCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/milestones/" + milestoneCode) + if err != nil { + output.Errorf("failed to get milestone: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var m milestoneResponse + if err := json.Unmarshal(data, &m); err != nil { + output.Errorf("cannot parse milestone: %v", err) + } + + desc := "" + if m.Description != nil { + desc = *m.Description + } + due := "" + if m.DueDate != nil { + due = *m.DueDate + } + output.PrintKeyValue( + "code", m.Code, + "title", m.Title, + "description", desc, + "status", m.Status, + "due-date", due, + "project", m.ProjectCode, + "created", m.CreatedAt, + ) +} + +// RunMilestoneCreate implements `hf milestone create`. +func RunMilestoneCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, desc, due := "", "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + case "--due": + if i+1 >= len(args) { + output.Error("--due requires a value") + } + i++ + due = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if project == "" || title == "" { + output.Error("usage: hf milestone create --project --title ") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + } + if desc != "" { + payload["description"] = desc + } + if due != "" { + payload["due_date"] = due + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/milestones", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create milestone: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var m milestoneResponse + if err := json.Unmarshal(data, &m); err != nil { + fmt.Printf("milestone created: %s\n", title) + return + } + fmt.Printf("milestone created: %s (code: %s)\n", m.Title, m.Code) +} + +// RunMilestoneUpdate implements `hf milestone update <milestone-code>`. +func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + payload["status"] = args[i] + case "--due": + if i+1 >= len(args) { + output.Error("--due requires a value") + } + i++ + payload["due_date"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/milestones/"+milestoneCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update milestone: %v", err) + } + + fmt.Printf("milestone updated: %s\n", milestoneCode) +} + +// RunMilestoneDelete implements `hf milestone delete <milestone-code>`. +func RunMilestoneDelete(milestoneCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/milestones/" + milestoneCode) + if err != nil { + output.Errorf("failed to delete milestone: %v", err) + } + fmt.Printf("milestone deleted: %s\n", milestoneCode) +} + +// RunMilestoneProgress implements `hf milestone progress <milestone-code>`. +func RunMilestoneProgress(milestoneCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/milestones/" + milestoneCode + "/progress") + if err != nil { + output.Errorf("failed to get milestone progress: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var p milestoneProgressResponse + if err := json.Unmarshal(data, &p); err != nil { + output.Errorf("cannot parse progress: %v", err) + } + + output.PrintKeyValue( + "code", p.Code, + "title", p.Title, + "status", p.Status, + "total-tasks", fmt.Sprintf("%d", p.TotalTasks), + "done-tasks", fmt.Sprintf("%d", p.DoneTasks), + "progress", fmt.Sprintf("%.1f%%", p.Progress*100), + ) +} diff --git a/internal/commands/project.go b/internal/commands/project.go new file mode 100644 index 0000000..12ed9f0 --- /dev/null +++ b/internal/commands/project.go @@ -0,0 +1,413 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// projectResponse matches the backend ProjectResponse schema. +type projectResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description *string `json:"description"` + Repo *string `json:"repo"` + Owner string `json:"owner"` + CreatedAt string `json:"created_at"` +} + +// projectMemberResponse matches the backend ProjectMemberResponse schema. +type projectMemberResponse struct { + Username string `json:"username"` + RoleName string `json:"role_name"` +} + +// RunProjectList implements `hf project list`. +func RunProjectList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + // Build query params + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--owner": + if i+1 >= len(args) { + output.Error("--owner requires a value") + } + i++ + query = appendQuery(query, "owner", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/projects" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list projects: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var projects []projectResponse + if err := json.Unmarshal(data, &projects); err != nil { + output.Errorf("cannot parse project list: %v", err) + } + + headers := []string{"CODE", "NAME", "OWNER", "DESCRIPTION"} + var rows [][]string + for _, p := range projects { + desc := "" + if p.Description != nil { + desc = *p.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + } + rows = append(rows, []string{p.Code, p.Name, p.Owner, desc}) + } + output.PrintTable(headers, rows) +} + +// RunProjectGet implements `hf project get <project-code>`. +func RunProjectGet(projectCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/projects/" + projectCode) + if err != nil { + output.Errorf("failed to get project: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var p projectResponse + if err := json.Unmarshal(data, &p); err != nil { + output.Errorf("cannot parse project: %v", err) + } + + desc := "" + if p.Description != nil { + desc = *p.Description + } + repo := "" + if p.Repo != nil { + repo = *p.Repo + } + output.PrintKeyValue( + "code", p.Code, + "name", p.Name, + "description", desc, + "repo", repo, + "owner", p.Owner, + "created", p.CreatedAt, + ) +} + +// RunProjectCreate implements `hf project create`. +func RunProjectCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + name, desc, repo := "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--name": + if i+1 >= len(args) { + output.Error("--name requires a value") + } + i++ + name = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + case "--repo": + if i+1 >= len(args) { + output.Error("--repo requires a value") + } + i++ + repo = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if name == "" { + output.Error("usage: hf project create --name <name>") + } + + payload := map[string]interface{}{ + "name": name, + } + if desc != "" { + payload["description"] = desc + } + if repo != "" { + payload["repo"] = repo + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/projects", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create project: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var p projectResponse + if err := json.Unmarshal(data, &p); err != nil { + fmt.Printf("project created: %s\n", name) + return + } + fmt.Printf("project created: %s (code: %s)\n", p.Name, p.Code) +} + +// RunProjectUpdate implements `hf project update <project-code>`. +func RunProjectUpdate(projectCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--name": + if i+1 >= len(args) { + output.Error("--name requires a value") + } + i++ + payload["name"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--repo": + if i+1 >= len(args) { + output.Error("--repo requires a value") + } + i++ + payload["repo"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/projects/"+projectCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update project: %v", err) + } + + fmt.Printf("project updated: %s\n", projectCode) +} + +// RunProjectDelete implements `hf project delete <project-code>`. +func RunProjectDelete(projectCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/projects/" + projectCode) + if err != nil { + output.Errorf("failed to delete project: %v", err) + } + fmt.Printf("project deleted: %s\n", projectCode) +} + +// RunProjectMembers implements `hf project members <project-code>`. +func RunProjectMembers(projectCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/projects/" + projectCode + "/members") + if err != nil { + output.Errorf("failed to list project members: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var members []projectMemberResponse + if err := json.Unmarshal(data, &members); err != nil { + output.Errorf("cannot parse member list: %v", err) + } + + headers := []string{"USERNAME", "ROLE"} + var rows [][]string + for _, m := range members { + rows = append(rows, []string{m.Username, m.RoleName}) + } + output.PrintTable(headers, rows) +} + +// RunProjectAddMember implements `hf project add-member <project-code> --user <username> --role <role-name>`. +func RunProjectAddMember(projectCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + username, roleName := "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--user": + if i+1 >= len(args) { + output.Error("--user requires a value") + } + i++ + username = args[i] + case "--role": + if i+1 >= len(args) { + output.Error("--role requires a value") + } + i++ + roleName = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if username == "" || roleName == "" { + output.Error("usage: hf project add-member <project-code> --user <username> --role <role-name>") + } + + payload := map[string]interface{}{ + "username": username, + "role_name": roleName, + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/projects/"+projectCode+"/members", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add member: %v", err) + } + + fmt.Printf("member %s added to project %s with role %s\n", username, projectCode, roleName) +} + +// RunProjectRemoveMember implements `hf project remove-member <project-code> --user <username>`. +func RunProjectRemoveMember(projectCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + username := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--user": + if i+1 >= len(args) { + output.Error("--user requires a value") + } + i++ + username = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if username == "" { + output.Error("usage: hf project remove-member <project-code> --user <username>") + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/projects/" + projectCode + "/members/" + username) + if err != nil { + output.Errorf("failed to remove member: %v", err) + } + + fmt.Printf("member %s removed from project %s\n", username, projectCode) +} + +// appendQuery is a helper for building query strings. +func appendQuery(existing, key, value string) string { + if existing == "" { + return key + "=" + value + } + return existing + "&" + key + "=" + value +} diff --git a/internal/commands/role.go b/internal/commands/role.go new file mode 100644 index 0000000..bfe4732 --- /dev/null +++ b/internal/commands/role.go @@ -0,0 +1,334 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// roleResponse matches the backend RoleResponse schema. +type roleResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsGlobal bool `json:"is_global"` + Permissions []string `json:"permissions"` + CreatedAt string `json:"created_at"` +} + +// permissionResponse matches the backend PermissionResponse schema. +type permissionResponse struct { + ID int `json:"id"` + Codename string `json:"codename"` + Description string `json:"description"` +} + +// RunRoleList implements `hf role list`. +func RunRoleList(tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/roles") + if err != nil { + output.Errorf("failed to list roles: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var roles []roleResponse + if err := json.Unmarshal(data, &roles); err != nil { + output.Errorf("cannot parse role list: %v", err) + } + + headers := []string{"NAME", "DESCRIPTION", "GLOBAL", "PERMISSIONS"} + var rows [][]string + for _, r := range roles { + global := "" + if r.IsGlobal { + global = "yes" + } + perms := strings.Join(r.Permissions, ", ") + if len(perms) > 60 { + perms = perms[:57] + "..." + } + rows = append(rows, []string{r.Name, r.Description, global, perms}) + } + output.PrintTable(headers, rows) +} + +// RunRoleGet implements `hf role get <role-name>`. +func RunRoleGet(roleName, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/roles/" + roleName) + if err != nil { + output.Errorf("failed to get role: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var r roleResponse + if err := json.Unmarshal(data, &r); err != nil { + output.Errorf("cannot parse role: %v", err) + } + + global := "no" + if r.IsGlobal { + global = "yes" + } + perms := "(none)" + if len(r.Permissions) > 0 { + perms = strings.Join(r.Permissions, ", ") + } + output.PrintKeyValue( + "name", r.Name, + "description", r.Description, + "global", global, + "permissions", perms, + "created", r.CreatedAt, + ) +} + +// RunRoleCreate implements `hf role create`. +func RunRoleCreate(name, desc string, global bool, tokenFlag string) { + token := ResolveToken(tokenFlag) + if name == "" { + output.Error("usage: hf role create --name <role-name>") + } + + payload := map[string]interface{}{ + "name": name, + } + if desc != "" { + payload["description"] = desc + } + if global { + payload["is_global"] = true + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/roles", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create role: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + fmt.Printf("role created: %s\n", name) +} + +// RunRoleUpdate implements `hf role update <role-name>`. +func RunRoleUpdate(roleName string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/roles/"+roleName, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update role: %v", err) + } + + fmt.Printf("role updated: %s\n", roleName) +} + +// RunRoleDelete implements `hf role delete <role-name>`. +func RunRoleDelete(roleName, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/roles/" + roleName) + if err != nil { + output.Errorf("failed to delete role: %v", err) + } + fmt.Printf("role deleted: %s\n", roleName) +} + +// RunRoleSetPermissions implements `hf role set-permissions <role-name> --permission <perm> [...]`. +func RunRoleSetPermissions(roleName string, permissions []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + if len(permissions) == 0 { + output.Error("usage: hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]") + } + + payload := map[string]interface{}{ + "permissions": permissions, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Put("/roles/"+roleName+"/permissions", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to set permissions: %v", err) + } + + fmt.Printf("permissions set for role %s: %s\n", roleName, strings.Join(permissions, ", ")) +} + +// RunRoleAddPermissions implements `hf role add-permissions <role-name> --permission <perm> [...]`. +func RunRoleAddPermissions(roleName string, permissions []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + if len(permissions) == 0 { + output.Error("usage: hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]") + } + + payload := map[string]interface{}{ + "permissions": permissions, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/roles/"+roleName+"/permissions", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add permissions: %v", err) + } + + fmt.Printf("permissions added to role %s: %s\n", roleName, strings.Join(permissions, ", ")) +} + +// RunRoleRemovePermissions implements `hf role remove-permissions <role-name> --permission <perm> [...]`. +func RunRoleRemovePermissions(roleName string, permissions []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + if len(permissions) == 0 { + output.Error("usage: hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]") + } + + payload := map[string]interface{}{ + "permissions": permissions, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Do("DELETE", "/roles/"+roleName+"/permissions", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to remove permissions: %v", err) + } + + fmt.Printf("permissions removed from role %s: %s\n", roleName, strings.Join(permissions, ", ")) +} + +// RunPermissionList implements `hf permission list`. +func RunPermissionList(tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/permissions") + if err != nil { + output.Errorf("failed to list permissions: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var perms []permissionResponse + if err := json.Unmarshal(data, &perms); err != nil { + output.Errorf("cannot parse permission list: %v", err) + } + + headers := []string{"CODENAME", "DESCRIPTION"} + var rows [][]string + for _, p := range perms { + rows = append(rows, []string{p.Codename, p.Description}) + } + output.PrintTable(headers, rows) +} diff --git a/internal/commands/task.go b/internal/commands/task.go new file mode 100644 index 0000000..b006221 --- /dev/null +++ b/internal/commands/task.go @@ -0,0 +1,478 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// taskResponse matches the backend TaskResponse schema. +type taskResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Type string `json:"type"` + DueDate *string `json:"due_date"` + ProjectCode string `json:"project_code"` + MilestoneCode *string `json:"milestone_code"` + TakenBy *string `json:"taken_by"` + CreatedAt string `json:"created_at"` +} + +// RunTaskList implements `hf task list`. +func RunTaskList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + query = appendQuery(query, "project", args[i]) + case "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + query = appendQuery(query, "milestone", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--taken-by": + if i+1 >= len(args) { + output.Error("--taken-by requires a value") + } + i++ + query = appendQuery(query, "taken_by", args[i]) + case "--due-today": + if i+1 >= len(args) { + output.Error("--due-today requires a value") + } + i++ + query = appendQuery(query, "due_today", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/tasks" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list tasks: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var tasks []taskResponse + if err := json.Unmarshal(data, &tasks); err != nil { + output.Errorf("cannot parse task list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY", "PROJECT"} + var rows [][]string + for _, t := range tasks { + takenBy := "" + if t.TakenBy != nil { + takenBy = *t.TakenBy + } + title := t.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{t.Code, title, t.Status, t.Priority, takenBy, t.ProjectCode}) + } + output.PrintTable(headers, rows) +} + +// RunTaskGet implements `hf task get <task-code>`. +func RunTaskGet(taskCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/tasks/" + taskCode) + if err != nil { + output.Errorf("failed to get task: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var t taskResponse + if err := json.Unmarshal(data, &t); err != nil { + output.Errorf("cannot parse task: %v", err) + } + + desc := "" + if t.Description != nil { + desc = *t.Description + } + due := "" + if t.DueDate != nil { + due = *t.DueDate + } + milestone := "" + if t.MilestoneCode != nil { + milestone = *t.MilestoneCode + } + takenBy := "" + if t.TakenBy != nil { + takenBy = *t.TakenBy + } + output.PrintKeyValue( + "code", t.Code, + "title", t.Title, + "description", desc, + "status", t.Status, + "priority", t.Priority, + "type", t.Type, + "due-date", due, + "project", t.ProjectCode, + "milestone", milestone, + "taken-by", takenBy, + "created", t.CreatedAt, + ) +} + +// RunTaskCreate implements `hf task create`. +func RunTaskCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, milestone, taskType, priority, desc := "", "", "", "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + milestone = args[i] + case "--type": + if i+1 >= len(args) { + output.Error("--type requires a value") + } + i++ + taskType = args[i] + case "--priority": + if i+1 >= len(args) { + output.Error("--priority requires a value") + } + i++ + priority = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if project == "" || title == "" { + output.Error("usage: hf task create --project <project-code> --title <title>") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + } + if milestone != "" { + payload["milestone_code"] = milestone + } + if taskType != "" { + payload["type"] = taskType + } + if priority != "" { + payload["priority"] = priority + } + if desc != "" { + payload["description"] = desc + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/tasks", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create task: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var t taskResponse + if err := json.Unmarshal(data, &t); err != nil { + fmt.Printf("task created: %s\n", title) + return + } + fmt.Printf("task created: %s (code: %s)\n", t.Title, t.Code) +} + +// RunTaskUpdate implements `hf task update <task-code>`. +func RunTaskUpdate(taskCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + payload["status"] = args[i] + case "--priority": + if i+1 >= len(args) { + output.Error("--priority requires a value") + } + i++ + payload["priority"] = args[i] + case "--assignee": + if i+1 >= len(args) { + output.Error("--assignee requires a value") + } + i++ + val := args[i] + if val == "null" { + payload["taken_by"] = nil + } else { + payload["taken_by"] = val + } + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/tasks/"+taskCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update task: %v", err) + } + + fmt.Printf("task updated: %s\n", taskCode) +} + +// RunTaskTransition implements `hf task transition <task-code> <status>`. +func RunTaskTransition(taskCode, status, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := map[string]interface{}{ + "status": status, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/tasks/"+taskCode+"/transition", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to transition task: %v", err) + } + + fmt.Printf("task %s transitioned to %s\n", taskCode, status) +} + +// RunTaskTake implements `hf task take <task-code>`. +func RunTaskTake(taskCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/tasks/"+taskCode+"/take", nil) + if err != nil { + output.Errorf("failed to take task: %v", err) + } + + fmt.Printf("task taken: %s\n", taskCode) +} + +// RunTaskDelete implements `hf task delete <task-code>`. +func RunTaskDelete(taskCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/tasks/" + taskCode) + if err != nil { + output.Errorf("failed to delete task: %v", err) + } + fmt.Printf("task deleted: %s\n", taskCode) +} + +// RunTaskSearch implements `hf task search`. +func RunTaskSearch(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--query": + if i+1 >= len(args) { + output.Error("--query requires a value") + } + i++ + query = appendQuery(query, "q", args[i]) + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + query = appendQuery(query, "project", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/tasks/search" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to search tasks: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var tasks []taskResponse + if err := json.Unmarshal(data, &tasks); err != nil { + output.Errorf("cannot parse task list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY", "PROJECT"} + var rows [][]string + for _, t := range tasks { + takenBy := "" + if t.TakenBy != nil { + takenBy = *t.TakenBy + } + title := t.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{t.Code, title, t.Status, t.Priority, takenBy, t.ProjectCode}) + } + output.PrintTable(headers, rows) +} -- 2.49.1 From a01e6021180b250af4dea4f7f98a8aecaa69c8d8 Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 15:06:57 +0000 Subject: [PATCH 07/15] Align role commands with current backend API --- README.md | 1 + internal/commands/role.go | 319 +++++++++++++++++++++++++++----------- 2 files changed, 232 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index ea7c864..f03ae40 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Implemented: - Output formatting (human-readable + `--json`) - `hf version`, `hf health`, `hf config` - Auth token resolution (padded-cell + manual) +- Backend-aligned role/permission commands, including role-name lookup and permission-name↔id translation against current API routes Planned: - User, role, project, task, milestone, meeting, support, propose, monitor commands diff --git a/internal/commands/role.go b/internal/commands/role.go index bfe4732..219c1ee 100644 --- a/internal/commands/role.go +++ b/internal/commands/role.go @@ -11,31 +11,145 @@ import ( "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" ) -// roleResponse matches the backend RoleResponse schema. +// roleResponse matches the backend role list schema. type roleResponse struct { - ID int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - IsGlobal bool `json:"is_global"` - Permissions []string `json:"permissions"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsGlobal bool `json:"is_global"` + PermissionIDs []int `json:"permission_ids"` + CreatedAt string `json:"created_at"` } -// permissionResponse matches the backend PermissionResponse schema. +// roleDetailResponse matches the backend role detail schema. +type roleDetailResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsGlobal bool `json:"is_global"` + Permissions []permissionResponse `json:"permissions"` + CreatedAt string `json:"created_at"` +} + +// permissionResponse matches the backend permission schema. type permissionResponse struct { ID int `json:"id"` - Codename string `json:"codename"` + Name string `json:"name"` Description string `json:"description"` + Category string `json:"category"` +} + +func loadRoleClient(tokenFlag string) (*client.Client, error) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("config error: %w", err) + } + return client.New(cfg.BaseURL, token), nil +} + +func fetchRoles(c *client.Client) ([]roleResponse, error) { + data, err := c.Get("/roles") + if err != nil { + return nil, err + } + var roles []roleResponse + if err := json.Unmarshal(data, &roles); err != nil { + return nil, fmt.Errorf("cannot parse role list: %w", err) + } + return roles, nil +} + +func fetchPermissions(c *client.Client) ([]permissionResponse, error) { + data, err := c.Get("/roles/permissions") + if err != nil { + return nil, err + } + var perms []permissionResponse + if err := json.Unmarshal(data, &perms); err != nil { + return nil, fmt.Errorf("cannot parse permission list: %w", err) + } + return perms, nil +} + +func findRoleByName(c *client.Client, roleName string) (*roleResponse, error) { + roles, err := fetchRoles(c) + if err != nil { + return nil, err + } + for _, r := range roles { + if r.Name == roleName { + role := r + return &role, nil + } + } + return nil, fmt.Errorf("role not found: %s", roleName) +} + +func fetchRoleDetailByName(c *client.Client, roleName string) (*roleDetailResponse, error) { + role, err := findRoleByName(c, roleName) + if err != nil { + return nil, err + } + data, err := c.Get(fmt.Sprintf("/roles/%d", role.ID)) + if err != nil { + return nil, err + } + var detail roleDetailResponse + if err := json.Unmarshal(data, &detail); err != nil { + return nil, fmt.Errorf("cannot parse role detail: %w", err) + } + return &detail, nil +} + +func resolvePermissionIDs(c *client.Client, names []string) ([]int, error) { + perms, err := fetchPermissions(c) + if err != nil { + return nil, err + } + byName := make(map[string]int, len(perms)) + for _, p := range perms { + byName[p.Name] = p.ID + } + ids := make([]int, 0, len(names)) + seen := map[int]struct{}{} + var missing []string + for _, name := range names { + id, ok := byName[name] + if !ok { + missing = append(missing, name) + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(missing) > 0 { + return nil, fmt.Errorf("unknown permission(s): %s", strings.Join(missing, ", ")) + } + return ids, nil +} + +func replaceRolePermissions(c *client.Client, roleID int, permissionIDs []int) error { + payload := map[string]interface{}{ + "permission_ids": permissionIDs, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("cannot marshal payload: %w", err) + } + _, err = c.Post(fmt.Sprintf("/roles/%d/permissions", roleID), bytes.NewReader(body)) + return err } // RunRoleList implements `hf role list`. func RunRoleList(tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) data, err := c.Get("/roles") if err != nil { output.Errorf("failed to list roles: %v", err) @@ -55,31 +169,37 @@ func RunRoleList(tokenFlag string) { output.Errorf("cannot parse role list: %v", err) } - headers := []string{"NAME", "DESCRIPTION", "GLOBAL", "PERMISSIONS"} + headers := []string{"NAME", "DESCRIPTION", "GLOBAL", "PERM IDS"} var rows [][]string for _, r := range roles { global := "" if r.IsGlobal { global = "yes" } - perms := strings.Join(r.Permissions, ", ") - if len(perms) > 60 { - perms = perms[:57] + "..." + permIDs := "" + if len(r.PermissionIDs) > 0 { + parts := make([]string, 0, len(r.PermissionIDs)) + for _, id := range r.PermissionIDs { + parts = append(parts, fmt.Sprintf("%d", id)) + } + permIDs = strings.Join(parts, ", ") } - rows = append(rows, []string{r.Name, r.Description, global, perms}) + rows = append(rows, []string{r.Name, r.Description, global, permIDs}) } output.PrintTable(headers, rows) } // RunRoleGet implements `hf role get <role-name>`. func RunRoleGet(roleName, tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) - data, err := c.Get("/roles/" + roleName) + role, err := findRoleByName(c, roleName) + if err != nil { + output.Errorf("failed to get role: %v", err) + } + data, err := c.Get(fmt.Sprintf("/roles/%d", role.ID)) if err != nil { output.Errorf("failed to get role: %v", err) } @@ -93,7 +213,7 @@ func RunRoleGet(roleName, tokenFlag string) { return } - var r roleResponse + var r roleDetailResponse if err := json.Unmarshal(data, &r); err != nil { output.Errorf("cannot parse role: %v", err) } @@ -104,20 +224,26 @@ func RunRoleGet(roleName, tokenFlag string) { } perms := "(none)" if len(r.Permissions) > 0 { - perms = strings.Join(r.Permissions, ", ") + names := make([]string, 0, len(r.Permissions)) + for _, p := range r.Permissions { + names = append(names, p.Name) + } + perms = strings.Join(names, ", ") } output.PrintKeyValue( "name", r.Name, "description", r.Description, "global", global, "permissions", perms, - "created", r.CreatedAt, ) } // RunRoleCreate implements `hf role create`. func RunRoleCreate(name, desc string, global bool, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if name == "" { output.Error("usage: hf role create --name <role-name>") } @@ -137,11 +263,6 @@ func RunRoleCreate(name, desc string, global bool, tokenFlag string) { output.Errorf("cannot marshal payload: %v", err) } - cfg, err := config.Load() - if err != nil { - output.Errorf("config error: %v", err) - } - c := client.New(cfg.BaseURL, token) data, err := c.Post("/roles", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create role: %v", err) @@ -161,7 +282,14 @@ func RunRoleCreate(name, desc string, global bool, tokenFlag string) { // RunRoleUpdate implements `hf role update <role-name>`. func RunRoleUpdate(roleName string, args []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } + role, err := findRoleByName(c, roleName) + if err != nil { + output.Errorf("failed to update role: %v", err) + } payload := make(map[string]interface{}) for i := 0; i < len(args); i++ { @@ -186,12 +314,7 @@ func RunRoleUpdate(roleName string, args []string, tokenFlag string) { output.Errorf("cannot marshal payload: %v", err) } - cfg, err := config.Load() - if err != nil { - output.Errorf("config error: %v", err) - } - c := client.New(cfg.BaseURL, token) - _, err = c.Patch("/roles/"+roleName, bytes.NewReader(body)) + _, err = c.Patch(fmt.Sprintf("/roles/%d", role.ID), bytes.NewReader(body)) if err != nil { output.Errorf("failed to update role: %v", err) } @@ -201,13 +324,15 @@ func RunRoleUpdate(roleName string, args []string, tokenFlag string) { // RunRoleDelete implements `hf role delete <role-name>`. func RunRoleDelete(roleName, tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Delete("/roles/" + roleName) + role, err := findRoleByName(c, roleName) + if err != nil { + output.Errorf("failed to delete role: %v", err) + } + _, err = c.Delete(fmt.Sprintf("/roles/%d", role.ID)) if err != nil { output.Errorf("failed to delete role: %v", err) } @@ -216,26 +341,22 @@ func RunRoleDelete(roleName, tokenFlag string) { // RunRoleSetPermissions implements `hf role set-permissions <role-name> --permission <perm> [...]`. func RunRoleSetPermissions(roleName string, permissions []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if len(permissions) == 0 { output.Error("usage: hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]") } - - payload := map[string]interface{}{ - "permissions": permissions, - } - body, err := json.Marshal(payload) + role, err := findRoleByName(c, roleName) if err != nil { - output.Errorf("cannot marshal payload: %v", err) + output.Errorf("failed to set permissions: %v", err) } - - cfg, err := config.Load() + permissionIDs, err := resolvePermissionIDs(c, permissions) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("failed to set permissions: %v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Put("/roles/"+roleName+"/permissions", bytes.NewReader(body)) - if err != nil { + if err := replaceRolePermissions(c, role.ID, permissionIDs); err != nil { output.Errorf("failed to set permissions: %v", err) } @@ -244,69 +365,91 @@ func RunRoleSetPermissions(roleName string, permissions []string, tokenFlag stri // RunRoleAddPermissions implements `hf role add-permissions <role-name> --permission <perm> [...]`. func RunRoleAddPermissions(roleName string, permissions []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if len(permissions) == 0 { output.Error("usage: hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]") } - - payload := map[string]interface{}{ - "permissions": permissions, - } - body, err := json.Marshal(payload) + role, err := findRoleByName(c, roleName) if err != nil { - output.Errorf("cannot marshal payload: %v", err) + output.Errorf("failed to add permissions: %v", err) } - - cfg, err := config.Load() + detail, err := fetchRoleDetailByName(c, roleName) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("failed to add permissions: %v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Post("/roles/"+roleName+"/permissions", bytes.NewReader(body)) + currentIDs := make([]int, 0, len(detail.Permissions)) + seen := map[int]struct{}{} + for _, p := range detail.Permissions { + seen[p.ID] = struct{}{} + currentIDs = append(currentIDs, p.ID) + } + newIDs, err := resolvePermissionIDs(c, permissions) if err != nil { output.Errorf("failed to add permissions: %v", err) } + for _, id := range newIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + currentIDs = append(currentIDs, id) + } + if err := replaceRolePermissions(c, role.ID, currentIDs); err != nil { + output.Errorf("failed to add permissions: %v", err) + } fmt.Printf("permissions added to role %s: %s\n", roleName, strings.Join(permissions, ", ")) } // RunRoleRemovePermissions implements `hf role remove-permissions <role-name> --permission <perm> [...]`. func RunRoleRemovePermissions(roleName string, permissions []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if len(permissions) == 0 { output.Error("usage: hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]") } - - payload := map[string]interface{}{ - "permissions": permissions, - } - body, err := json.Marshal(payload) + role, err := findRoleByName(c, roleName) if err != nil { - output.Errorf("cannot marshal payload: %v", err) + output.Errorf("failed to remove permissions: %v", err) } - - cfg, err := config.Load() + detail, err := fetchRoleDetailByName(c, roleName) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("failed to remove permissions: %v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Do("DELETE", "/roles/"+roleName+"/permissions", bytes.NewReader(body)) + removeIDs, err := resolvePermissionIDs(c, permissions) if err != nil { output.Errorf("failed to remove permissions: %v", err) } + removeSet := map[int]struct{}{} + for _, id := range removeIDs { + removeSet[id] = struct{}{} + } + remaining := make([]int, 0, len(detail.Permissions)) + for _, p := range detail.Permissions { + if _, ok := removeSet[p.ID]; ok { + continue + } + remaining = append(remaining, p.ID) + } + if err := replaceRolePermissions(c, role.ID, remaining); err != nil { + output.Errorf("failed to remove permissions: %v", err) + } fmt.Printf("permissions removed from role %s: %s\n", roleName, strings.Join(permissions, ", ")) } // RunPermissionList implements `hf permission list`. func RunPermissionList(tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) - data, err := c.Get("/permissions") + data, err := c.Get("/roles/permissions") if err != nil { output.Errorf("failed to list permissions: %v", err) } @@ -325,10 +468,10 @@ func RunPermissionList(tokenFlag string) { output.Errorf("cannot parse permission list: %v", err) } - headers := []string{"CODENAME", "DESCRIPTION"} + headers := []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"} var rows [][]string for _, p := range perms { - rows = append(rows, []string{p.Codename, p.Description}) + rows = append(rows, []string{fmt.Sprintf("%d", p.ID), p.Name, p.Category, p.Description}) } output.PrintTable(headers, rows) } -- 2.49.1 From 34f52cb9e398ad215ec93acc3fb8dbb475c952da Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 15:24:43 +0000 Subject: [PATCH 08/15] feat: implement meeting, support, propose, and monitor command groups - Added meeting.go: list, get, create, update, attend, delete - Added support.go: list, get, create, update, take, transition, delete - Added propose.go: list, get, create, update, accept, reject, reopen - Added monitor.go: overview, server list/get/create/delete, api-key generate/revoke - Updated main.go with dispatch handlers for all four new groups - All commands follow existing patterns (token resolution, --json, table output) Covers TODO items 1.12, 1.13, 1.14, 1.15 from hf-cross-project-todo.md --- cmd/hf/main.go | 236 ++++++++++++++++++++++ internal/commands/meeting.go | 342 ++++++++++++++++++++++++++++++++ internal/commands/monitor.go | 279 ++++++++++++++++++++++++++ internal/commands/propose.go | 365 +++++++++++++++++++++++++++++++++++ internal/commands/support.go | 349 +++++++++++++++++++++++++++++++++ 5 files changed, 1571 insertions(+) create mode 100644 internal/commands/meeting.go create mode 100644 internal/commands/monitor.go create mode 100644 internal/commands/propose.go create mode 100644 internal/commands/support.go diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 8737869..46c8222 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -158,6 +158,18 @@ func handleGroup(group help.Group, args []string) { case "task": handleTaskCommand(sub.Name, remaining) return + case "meeting": + handleMeetingCommand(sub.Name, remaining) + return + case "support": + handleSupportCommand(sub.Name, remaining) + return + case "propose": + handleProposeCommand(sub.Name, remaining) + return + case "monitor": + handleMonitorCommand(sub.Name, remaining) + return } output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name) @@ -540,3 +552,227 @@ func handleProjectCommand(subCmd string, args []string) { output.Errorf("hf project %s is not implemented yet", subCmd) } } + +func handleMeetingCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunMeetingList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf meeting get <meeting-code>") + } + commands.RunMeetingGet(filtered[0], tokenFlag) + case "create": + commands.RunMeetingCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf meeting update <meeting-code> [--title ...] [--desc ...] [--status ...] [--time ...]") + } + commands.RunMeetingUpdate(filtered[0], filtered[1:], tokenFlag) + case "attend": + if len(filtered) < 1 { + output.Error("usage: hf meeting attend <meeting-code>") + } + commands.RunMeetingAttend(filtered[0], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf meeting delete <meeting-code>") + } + commands.RunMeetingDelete(filtered[0], tokenFlag) + default: + output.Errorf("hf meeting %s is not implemented yet", subCmd) + } +} + +func handleSupportCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunSupportList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf support get <support-code>") + } + commands.RunSupportGet(filtered[0], tokenFlag) + case "create": + commands.RunSupportCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf support update <support-code> [--title ...] [--desc ...] [--status ...] [--priority ...]") + } + commands.RunSupportUpdate(filtered[0], filtered[1:], tokenFlag) + case "take": + if len(filtered) < 1 { + output.Error("usage: hf support take <support-code>") + } + commands.RunSupportTake(filtered[0], tokenFlag) + case "transition": + if len(filtered) < 2 { + output.Error("usage: hf support transition <support-code> <status>") + } + commands.RunSupportTransition(filtered[0], filtered[1], tokenFlag) + case "delete": + if len(filtered) < 1 { + output.Error("usage: hf support delete <support-code>") + } + commands.RunSupportDelete(filtered[0], tokenFlag) + default: + output.Errorf("hf support %s is not implemented yet", subCmd) + } +} + +func handleProposeCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "list": + commands.RunProposeList(filtered, tokenFlag) + case "get": + if len(filtered) < 1 { + output.Error("usage: hf propose get <propose-code>") + } + commands.RunProposeGet(filtered[0], tokenFlag) + case "create": + commands.RunProposeCreate(filtered, tokenFlag) + case "update": + if len(filtered) < 1 { + output.Error("usage: hf propose update <propose-code> [--title ...] [--desc ...]") + } + commands.RunProposeUpdate(filtered[0], filtered[1:], tokenFlag) + case "accept": + if len(filtered) < 1 { + output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>") + } + commands.RunProposeAccept(filtered[0], filtered[1:], tokenFlag) + case "reject": + if len(filtered) < 1 { + output.Error("usage: hf propose reject <propose-code> [--reason <reason>]") + } + commands.RunProposeReject(filtered[0], filtered[1:], tokenFlag) + case "reopen": + if len(filtered) < 1 { + output.Error("usage: hf propose reopen <propose-code>") + } + commands.RunProposeReopen(filtered[0], tokenFlag) + default: + output.Errorf("hf propose %s is not implemented yet", subCmd) + } +} + +func handleMonitorCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "overview": + commands.RunMonitorOverview(tokenFlag) + case "server": + handleMonitorServerCommand(filtered, tokenFlag) + case "api-key": + handleMonitorAPIKeyCommand(filtered, tokenFlag) + default: + output.Errorf("hf monitor %s is not implemented yet", subCmd) + } +} + +func handleMonitorServerCommand(args []string, tokenFlag string) { + if len(args) == 0 { + output.Error("usage: hf monitor server <list|get|create|delete> ...") + } + + subCmd := args[0] + remaining := args[1:] + + switch subCmd { + case "list": + commands.RunMonitorServerList(tokenFlag) + case "get": + if len(remaining) < 1 { + output.Error("usage: hf monitor server get <identifier>") + } + commands.RunMonitorServerGet(remaining[0], tokenFlag) + case "create": + commands.RunMonitorServerCreate(remaining, tokenFlag) + case "delete": + if len(remaining) < 1 { + output.Error("usage: hf monitor server delete <identifier>") + } + commands.RunMonitorServerDelete(remaining[0], tokenFlag) + default: + output.Errorf("unknown monitor server subcommand: %s", subCmd) + } +} + +func handleMonitorAPIKeyCommand(args []string, tokenFlag string) { + if len(args) == 0 { + output.Error("usage: hf monitor api-key <generate|revoke> <identifier>") + } + + subCmd := args[0] + remaining := args[1:] + + switch subCmd { + case "generate": + if len(remaining) < 1 { + output.Error("usage: hf monitor api-key generate <identifier>") + } + commands.RunMonitorAPIKeyGenerate(remaining[0], tokenFlag) + case "revoke": + if len(remaining) < 1 { + output.Error("usage: hf monitor api-key revoke <identifier>") + } + commands.RunMonitorAPIKeyRevoke(remaining[0], tokenFlag) + default: + output.Errorf("unknown monitor api-key subcommand: %s", subCmd) + } +} diff --git a/internal/commands/meeting.go b/internal/commands/meeting.go new file mode 100644 index 0000000..0d34285 --- /dev/null +++ b/internal/commands/meeting.go @@ -0,0 +1,342 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// meetingResponse matches the backend MeetingResponse schema. +type meetingResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + MeetingTime *string `json:"meeting_time"` + ProjectCode string `json:"project_code"` + MilestoneCode *string `json:"milestone_code"` + Participants []string `json:"participants"` + CreatedAt string `json:"created_at"` +} + +// RunMeetingList implements `hf meeting list`. +func RunMeetingList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + query = appendQuery(query, "project", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/meetings" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list meetings: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var meetings []meetingResponse + if err := json.Unmarshal(data, &meetings); err != nil { + output.Errorf("cannot parse meeting list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "TIME", "PROJECT"} + var rows [][]string + for _, m := range meetings { + meetTime := "" + if m.MeetingTime != nil { + meetTime = *m.MeetingTime + } + title := m.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{m.Code, title, m.Status, meetTime, m.ProjectCode}) + } + output.PrintTable(headers, rows) +} + +// RunMeetingGet implements `hf meeting get <meeting-code>`. +func RunMeetingGet(meetingCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/meetings/" + meetingCode) + if err != nil { + output.Errorf("failed to get meeting: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var m meetingResponse + if err := json.Unmarshal(data, &m); err != nil { + output.Errorf("cannot parse meeting: %v", err) + } + + desc := "" + if m.Description != nil { + desc = *m.Description + } + meetTime := "" + if m.MeetingTime != nil { + meetTime = *m.MeetingTime + } + milestone := "" + if m.MilestoneCode != nil { + milestone = *m.MilestoneCode + } + participants := "" + if len(m.Participants) > 0 { + for i, p := range m.Participants { + if i > 0 { + participants += ", " + } + participants += p + } + } + output.PrintKeyValue( + "code", m.Code, + "title", m.Title, + "description", desc, + "status", m.Status, + "time", meetTime, + "project", m.ProjectCode, + "milestone", milestone, + "participants", participants, + "created", m.CreatedAt, + ) +} + +// RunMeetingCreate implements `hf meeting create`. +func RunMeetingCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, milestone, desc, meetTime := "", "", "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + milestone = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + case "--time": + if i+1 >= len(args) { + output.Error("--time requires a value") + } + i++ + meetTime = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if project == "" || title == "" { + output.Error("usage: hf meeting create --project <project-code> --title <title>") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + } + if milestone != "" { + payload["milestone_code"] = milestone + } + if desc != "" { + payload["description"] = desc + } + if meetTime != "" { + payload["meeting_time"] = meetTime + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/meetings", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create meeting: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var m meetingResponse + if err := json.Unmarshal(data, &m); err != nil { + fmt.Printf("meeting created: %s\n", title) + return + } + fmt.Printf("meeting created: %s (code: %s)\n", m.Title, m.Code) +} + +// RunMeetingUpdate implements `hf meeting update <meeting-code>`. +func RunMeetingUpdate(meetingCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + payload["status"] = args[i] + case "--time": + if i+1 >= len(args) { + output.Error("--time requires a value") + } + i++ + payload["meeting_time"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/meetings/"+meetingCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update meeting: %v", err) + } + + fmt.Printf("meeting updated: %s\n", meetingCode) +} + +// RunMeetingAttend implements `hf meeting attend <meeting-code>`. +func RunMeetingAttend(meetingCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/meetings/"+meetingCode+"/attend", nil) + if err != nil { + output.Errorf("failed to attend meeting: %v", err) + } + + fmt.Printf("attending meeting: %s\n", meetingCode) +} + +// RunMeetingDelete implements `hf meeting delete <meeting-code>`. +func RunMeetingDelete(meetingCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/meetings/" + meetingCode) + if err != nil { + output.Errorf("failed to delete meeting: %v", err) + } + fmt.Printf("meeting deleted: %s\n", meetingCode) +} diff --git a/internal/commands/monitor.go b/internal/commands/monitor.go new file mode 100644 index 0000000..ffe1a40 --- /dev/null +++ b/internal/commands/monitor.go @@ -0,0 +1,279 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// monitorOverviewResponse matches the backend monitor overview schema. +type monitorOverviewResponse struct { + TotalServers int `json:"total_servers"` + OnlineServers int `json:"online_servers"` +} + +// monitorServerResponse matches the backend monitor server schema. +type monitorServerResponse struct { + ID int `json:"id"` + Identifier string `json:"identifier"` + DisplayName *string `json:"display_name"` + Status string `json:"status"` + LastSeen *string `json:"last_seen"` + CreatedAt string `json:"created_at"` +} + +// monitorAPIKeyResponse matches the backend monitor API key schema. +type monitorAPIKeyResponse struct { + Identifier string `json:"identifier"` + APIKey string `json:"api_key"` +} + +// RunMonitorOverview implements `hf monitor overview`. +func RunMonitorOverview(tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/monitor/overview") + if err != nil { + output.Errorf("failed to get monitor overview: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var o monitorOverviewResponse + if err := json.Unmarshal(data, &o); err != nil { + output.Errorf("cannot parse monitor overview: %v", err) + } + + output.PrintKeyValue( + "total-servers", fmt.Sprintf("%d", o.TotalServers), + "online-servers", fmt.Sprintf("%d", o.OnlineServers), + ) +} + +// RunMonitorServerList implements `hf monitor server list`. +func RunMonitorServerList(tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/monitor/servers") + if err != nil { + output.Errorf("failed to list monitor servers: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var servers []monitorServerResponse + if err := json.Unmarshal(data, &servers); err != nil { + output.Errorf("cannot parse server list: %v", err) + } + + headers := []string{"IDENTIFIER", "NAME", "STATUS", "LAST SEEN"} + var rows [][]string + for _, s := range servers { + name := "" + if s.DisplayName != nil { + name = *s.DisplayName + } + lastSeen := "" + if s.LastSeen != nil { + lastSeen = *s.LastSeen + } + rows = append(rows, []string{s.Identifier, name, s.Status, lastSeen}) + } + output.PrintTable(headers, rows) +} + +// RunMonitorServerGet implements `hf monitor server get <identifier>`. +func RunMonitorServerGet(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/monitor/servers/" + identifier) + if err != nil { + output.Errorf("failed to get server: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var s monitorServerResponse + if err := json.Unmarshal(data, &s); err != nil { + output.Errorf("cannot parse server: %v", err) + } + + name := "" + if s.DisplayName != nil { + name = *s.DisplayName + } + lastSeen := "" + if s.LastSeen != nil { + lastSeen = *s.LastSeen + } + output.PrintKeyValue( + "identifier", s.Identifier, + "name", name, + "status", s.Status, + "last-seen", lastSeen, + "created", s.CreatedAt, + ) +} + +// RunMonitorServerCreate implements `hf monitor server create --identifier <identifier>`. +func RunMonitorServerCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + identifier, name := "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--identifier": + if i+1 >= len(args) { + output.Error("--identifier requires a value") + } + i++ + identifier = args[i] + case "--name": + if i+1 >= len(args) { + output.Error("--name requires a value") + } + i++ + name = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if identifier == "" { + output.Error("usage: hf monitor server create --identifier <identifier>") + } + + payload := map[string]interface{}{ + "identifier": identifier, + } + if name != "" { + payload["display_name"] = name + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/monitor/servers", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create server: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + fmt.Printf("monitor server created: %s\n", identifier) + _ = data +} + +// RunMonitorServerDelete implements `hf monitor server delete <identifier>`. +func RunMonitorServerDelete(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/monitor/servers/" + identifier) + if err != nil { + output.Errorf("failed to delete server: %v", err) + } + fmt.Printf("monitor server deleted: %s\n", identifier) +} + +// RunMonitorAPIKeyGenerate implements `hf monitor api-key generate <identifier>`. +func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/monitor/servers/"+identifier+"/api-key", nil) + if err != nil { + output.Errorf("failed to generate API key: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var k monitorAPIKeyResponse + if err := json.Unmarshal(data, &k); err != nil { + fmt.Printf("API key generated for: %s\n", identifier) + return + } + output.PrintKeyValue( + "identifier", k.Identifier, + "api-key", k.APIKey, + ) +} + +// RunMonitorAPIKeyRevoke implements `hf monitor api-key revoke <identifier>`. +func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/monitor/servers/" + identifier + "/api-key") + if err != nil { + output.Errorf("failed to revoke API key: %v", err) + } + fmt.Printf("API key revoked for: %s\n", identifier) +} diff --git a/internal/commands/propose.go b/internal/commands/propose.go new file mode 100644 index 0000000..1e7d95c --- /dev/null +++ b/internal/commands/propose.go @@ -0,0 +1,365 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// proposeResponse matches the backend ProposeResponse schema. +type proposeResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + ProjectCode string `json:"project_code"` + CreatedBy *string `json:"created_by"` + CreatedAt string `json:"created_at"` +} + +// RunProposeList implements `hf propose list --project <project-code>`. +func RunProposeList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + query = appendQuery(query, "project", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/proposes" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list proposals: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var proposes []proposeResponse + if err := json.Unmarshal(data, &proposes); err != nil { + output.Errorf("cannot parse proposal list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PROJECT", "CREATED BY"} + var rows [][]string + for _, p := range proposes { + createdBy := "" + if p.CreatedBy != nil { + createdBy = *p.CreatedBy + } + title := p.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{p.Code, title, p.Status, p.ProjectCode, createdBy}) + } + output.PrintTable(headers, rows) +} + +// RunProposeGet implements `hf propose get <propose-code>`. +func RunProposeGet(proposeCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/proposes/" + proposeCode) + if err != nil { + output.Errorf("failed to get proposal: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var p proposeResponse + if err := json.Unmarshal(data, &p); err != nil { + output.Errorf("cannot parse proposal: %v", err) + } + + desc := "" + if p.Description != nil { + desc = *p.Description + } + createdBy := "" + if p.CreatedBy != nil { + createdBy = *p.CreatedBy + } + output.PrintKeyValue( + "code", p.Code, + "title", p.Title, + "description", desc, + "status", p.Status, + "project", p.ProjectCode, + "created-by", createdBy, + "created", p.CreatedAt, + ) +} + +// RunProposeCreate implements `hf propose create`. +func RunProposeCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + project, title, desc := "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if project == "" || title == "" || desc == "" { + output.Error("usage: hf propose create --project <project-code> --title <title> --desc <desc>") + } + + payload := map[string]interface{}{ + "project_code": project, + "title": title, + "description": desc, + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/proposes", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create proposal: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var p proposeResponse + if err := json.Unmarshal(data, &p); err != nil { + fmt.Printf("proposal created: %s\n", title) + return + } + fmt.Printf("proposal created: %s (code: %s)\n", p.Title, p.Code) +} + +// RunProposeUpdate implements `hf propose update <propose-code>`. +func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/proposes/"+proposeCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update proposal: %v", err) + } + + fmt.Printf("proposal updated: %s\n", proposeCode) +} + +// RunProposeAccept implements `hf propose accept <propose-code> --milestone <milestone-code>`. +func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + milestone := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--milestone": + if i+1 >= len(args) { + output.Error("--milestone requires a value") + } + i++ + milestone = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if milestone == "" { + output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>") + } + + payload := map[string]interface{}{ + "milestone_code": milestone, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to accept proposal: %v", err) + } + + fmt.Printf("proposal accepted: %s\n", proposeCode) +} + +// RunProposeReject implements `hf propose reject <propose-code>`. +func RunProposeReject(proposeCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + reason := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--reason": + if i+1 >= len(args) { + output.Error("--reason requires a value") + } + i++ + reason = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + var body io.Reader + if reason != "" { + payload := map[string]interface{}{ + "reason": reason, + } + data, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + body = bytes.NewReader(data) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/proposes/"+proposeCode+"/reject", body) + if err != nil { + output.Errorf("failed to reject proposal: %v", err) + } + + fmt.Printf("proposal rejected: %s\n", proposeCode) +} + +// RunProposeReopen implements `hf propose reopen <propose-code>`. +func RunProposeReopen(proposeCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/proposes/"+proposeCode+"/reopen", nil) + if err != nil { + output.Errorf("failed to reopen proposal: %v", err) + } + + fmt.Printf("proposal reopened: %s\n", proposeCode) +} diff --git a/internal/commands/support.go b/internal/commands/support.go new file mode 100644 index 0000000..80fc18d --- /dev/null +++ b/internal/commands/support.go @@ -0,0 +1,349 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// supportResponse matches the backend SupportResponse schema. +type supportResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + ProjectCode *string `json:"project_code"` + TakenBy *string `json:"taken_by"` + CreatedAt string `json:"created_at"` +} + +// RunSupportList implements `hf support list`. +func RunSupportList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + query := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--taken-by": + if i+1 >= len(args) { + output.Error("--taken-by requires a value") + } + i++ + query = appendQuery(query, "taken_by", args[i]) + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + query = appendQuery(query, "status", args[i]) + case "--order-by": + if i+1 >= len(args) { + output.Error("--order-by requires a value") + } + i++ + query = appendQuery(query, "order_by", args[i]) + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/supports" + if query != "" { + path += "?" + query + } + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list support tickets: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var tickets []supportResponse + if err := json.Unmarshal(data, &tickets); err != nil { + output.Errorf("cannot parse support list: %v", err) + } + + headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY"} + var rows [][]string + for _, s := range tickets { + takenBy := "" + if s.TakenBy != nil { + takenBy = *s.TakenBy + } + title := s.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{s.Code, title, s.Status, s.Priority, takenBy}) + } + output.PrintTable(headers, rows) +} + +// RunSupportGet implements `hf support get <support-code>`. +func RunSupportGet(supportCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/supports/" + supportCode) + if err != nil { + output.Errorf("failed to get support ticket: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var s supportResponse + if err := json.Unmarshal(data, &s); err != nil { + output.Errorf("cannot parse support ticket: %v", err) + } + + desc := "" + if s.Description != nil { + desc = *s.Description + } + project := "" + if s.ProjectCode != nil { + project = *s.ProjectCode + } + takenBy := "" + if s.TakenBy != nil { + takenBy = *s.TakenBy + } + output.PrintKeyValue( + "code", s.Code, + "title", s.Title, + "description", desc, + "status", s.Status, + "priority", s.Priority, + "project", project, + "taken-by", takenBy, + "created", s.CreatedAt, + ) +} + +// RunSupportCreate implements `hf support create`. +func RunSupportCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + title, project, desc, priority := "", "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--project": + if i+1 >= len(args) { + output.Error("--project requires a value") + } + i++ + project = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + case "--priority": + if i+1 >= len(args) { + output.Error("--priority requires a value") + } + i++ + priority = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if title == "" { + output.Error("usage: hf support create --title <title>") + } + + payload := map[string]interface{}{ + "title": title, + } + if project != "" { + payload["project_code"] = project + } + if desc != "" { + payload["description"] = desc + } + if priority != "" { + payload["priority"] = priority + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/supports", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create support ticket: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var s supportResponse + if err := json.Unmarshal(data, &s); err != nil { + fmt.Printf("support ticket created: %s\n", title) + return + } + fmt.Printf("support ticket created: %s (code: %s)\n", s.Title, s.Code) +} + +// RunSupportUpdate implements `hf support update <support-code>`. +func RunSupportUpdate(supportCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + case "--status": + if i+1 >= len(args) { + output.Error("--status requires a value") + } + i++ + payload["status"] = args[i] + case "--priority": + if i+1 >= len(args) { + output.Error("--priority requires a value") + } + i++ + payload["priority"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/supports/"+supportCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update support ticket: %v", err) + } + + fmt.Printf("support ticket updated: %s\n", supportCode) +} + +// RunSupportTake implements `hf support take <support-code>`. +func RunSupportTake(supportCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/supports/"+supportCode+"/take", nil) + if err != nil { + output.Errorf("failed to take support ticket: %v", err) + } + + fmt.Printf("support ticket taken: %s\n", supportCode) +} + +// RunSupportTransition implements `hf support transition <support-code> <status>`. +func RunSupportTransition(supportCode, status, tokenFlag string) { + token := ResolveToken(tokenFlag) + + payload := map[string]interface{}{ + "status": status, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/supports/"+supportCode+"/transition", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to transition support ticket: %v", err) + } + + fmt.Printf("support ticket %s transitioned to %s\n", supportCode, status) +} + +// RunSupportDelete implements `hf support delete <support-code>`. +func RunSupportDelete(supportCode, tokenFlag string) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/supports/" + supportCode) + if err != nil { + output.Errorf("failed to delete support ticket: %v", err) + } + fmt.Printf("support ticket deleted: %s\n", supportCode) +} -- 2.49.1 From eaf4f215b55bb101882033119f6cfb9b99f8b42e Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 15:37:13 +0000 Subject: [PATCH 09/15] Add detailed leaf help output --- README.md | 1 + cmd/hf/main.go | 20 ++++- internal/help/leaf.go | 178 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 internal/help/leaf.go diff --git a/README.md b/README.md index f03ae40..0d82ddb 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Implemented: - Config file resolution relative to binary directory - Runtime mode detection (`pass_mgr` present/absent) - Top-level and group/leaf help rendering system (`--help` / `--help-brief` / `not permitted` stubs) +- Detailed leaf help text for implemented commands, with padded-cell/manual auth flag differences - Permission-aware command visibility via `/auth/me/permissions` when a token is available - HTTP client wrapper - Output formatting (human-readable + `--json`) diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 46c8222..09b3b43 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -58,7 +58,11 @@ func parseGlobalFlags(args []string) []string { } func handleLeafOrRun(name string, args []string, run func()) { - if isHelpFlagOnly(args) { + if isLeafHelpFlagOnly(args) { + if text, ok := help.RenderLeafHelp("", name); ok { + fmt.Print(text) + return + } fmt.Printf("hf %s\n", name) return } @@ -70,6 +74,10 @@ func handleLeafOrRun(name string, args []string, run func()) { func handleConfig(args []string) { if isHelpFlagOnly(args) { + if text, ok := help.RenderLeafHelp("config", "show"); ok { + fmt.Print(text) + return + } runConfigHelp() return } @@ -123,11 +131,15 @@ func handleGroup(group help.Group, args []string) { output.Errorf("unknown %s subcommand: %s", group.Name, args[0]) } - if len(args) > 1 && isHelpFlagOnly(args[1:]) { + if len(args) > 1 && isLeafHelpFlagOnly(args[1:]) { if !sub.Permitted { fmt.Println(help.RenderNotPermitted(group.Name, sub.Name)) return } + if text, ok := help.RenderLeafHelp(group.Name, sub.Name); ok { + fmt.Print(text) + return + } fmt.Printf("hf %s %s\n", group.Name, sub.Name) return } @@ -266,6 +278,10 @@ func isHelpFlagOnly(args []string) bool { return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") } +func isLeafHelpFlagOnly(args []string) bool { + return len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "--help-brief") +} + func findGroup(name string) (help.Group, bool) { for _, group := range help.CommandSurface() { if group.Name == name { diff --git a/internal/help/leaf.go b/internal/help/leaf.go new file mode 100644 index 0000000..6610f78 --- /dev/null +++ b/internal/help/leaf.go @@ -0,0 +1,178 @@ +package help + +import ( + "fmt" + "strings" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" +) + +type leafHelp struct { + Summary string + Usage []string + Flags []string + Notes []string +} + +func RenderLeafHelp(group, cmd string) (string, bool) { + spec, ok := leafHelpSpec(group, cmd) + if !ok { + return "", false + } + + var b strings.Builder + name := strings.TrimSpace(strings.TrimSpace(group + " " + cmd)) + b.WriteString(fmt.Sprintf("hf %s - %s\n", name, spec.Summary)) + b.WriteString("\nUsage:\n") + for _, line := range spec.Usage { + b.WriteString(" " + line + "\n") + } + if len(spec.Flags) > 0 { + b.WriteString("\nFlags:\n") + for _, line := range spec.Flags { + b.WriteString(" " + line + "\n") + } + } + if len(spec.Notes) > 0 { + b.WriteString("\nNotes:\n") + for _, line := range spec.Notes { + b.WriteString(" - " + line + "\n") + } + } + return b.String(), true +} + +func authFlagHelp() []string { + if mode.IsPaddedCell() { + return []string{ + "--json Output in JSON format", + } + } + return []string{ + "--token <token> HarborForge API token (required in manual mode)", + "--json Output in JSON format", + } +} + +func accountManagerFlagHelp() []string { + flags := []string{ + "--user <username> Username to create", + "--pass <password> Initial password", + "--email <email> Email address (defaults to <user>@harborforge.local)", + "--full-name <name> Full name", + "--json Output in JSON format", + } + if mode.IsPaddedCell() { + return flags + } + return append([]string{"--acc-mgr-token <token> Account-manager token (required in manual mode)"}, flags...) +} + +func leafHelpSpec(group, cmd string) (leafHelp, bool) { + specs := map[string]leafHelp{ + "config/show": { + Summary: "View current CLI configuration", + Usage: []string{"hf config"}, + Flags: []string{"--json Output in JSON format"}, + Notes: []string{ + "Configuration is stored in .hf-config.json next to the hf binary.", + }, + }, + "version": { + Summary: "Show CLI version", + Usage: []string{"hf version"}, + Flags: []string{"--json Output in JSON format"}, + }, + "health": { + Summary: "Check HarborForge API health", + Usage: []string{"hf health", "hf health --json"}, + Flags: authFlagHelp(), + }, + "config/url": { + Summary: "Set HarborForge API base URL", + Usage: []string{"hf config --url <hf-url>"}, + Notes: []string{"Writes base-url into .hf-config.json next to the hf binary."}, + }, + "config/acc-mgr-token": { + Summary: "Store the account-manager token via pass_mgr", + Usage: []string{"hf config --acc-mgr-token <token>"}, + Notes: []string{"Only available in padded-cell mode with pass_mgr installed."}, + }, + "user/create": { + Summary: "Create a user account", + Usage: []string{"hf user create --user <username> [--pass <password>] [--email <email>] [--full-name <name>]"}, + Flags: accountManagerFlagHelp(), + Notes: []string{ + "This command uses the account-manager token flow, not the normal user token flow.", + "In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to pass_mgr.", + }, + }, + "user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()}, + "user/get": {Summary: "Show a user by username", Usage: []string{"hf user get <username>"}, Flags: authFlagHelp()}, + "user/update": {Summary: "Update a user", Usage: []string{"hf user update <username> [--email <email>] [--full-name <name>] [--pass <password>] [--active <true|false>]"}, Flags: authFlagHelp()}, + "user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()}, + "user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()}, + "user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()}, + "role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()}, + "role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()}, + "role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()}, + "role/update": {Summary: "Update a role", Usage: []string{"hf role update <role-name> [--desc <desc>]"}, Flags: authFlagHelp()}, + "role/delete": {Summary: "Delete a role", Usage: []string{"hf role delete <role-name>"}, Flags: authFlagHelp()}, + "role/set-permissions": {Summary: "Replace role permissions", Usage: []string{"hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, + "role/add-permissions": {Summary: "Add permissions to a role", Usage: []string{"hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, + "role/remove-permissions": {Summary: "Remove permissions from a role", Usage: []string{"hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, + "permission/list": {Summary: "List permissions", Usage: []string{"hf permission list"}, Flags: authFlagHelp()}, + "project/list": {Summary: "List projects", Usage: []string{"hf project list [--owner <username>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "project/get": {Summary: "Show a project by code", Usage: []string{"hf project get <project-code>"}, Flags: authFlagHelp()}, + "project/create": {Summary: "Create a project", Usage: []string{"hf project create --name <name> [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()}, + "project/update": {Summary: "Update a project", Usage: []string{"hf project update <project-code> [--name <name>] [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()}, + "project/delete": {Summary: "Delete a project", Usage: []string{"hf project delete <project-code>"}, Flags: authFlagHelp()}, + "project/members": {Summary: "List project members", Usage: []string{"hf project members <project-code>"}, Flags: authFlagHelp()}, + "project/add-member": {Summary: "Add a member to a project", Usage: []string{"hf project add-member <project-code> --user <username> --role <role-name>"}, Flags: authFlagHelp()}, + "project/remove-member": {Summary: "Remove a member from a project", Usage: []string{"hf project remove-member <project-code> --user <username>"}, Flags: authFlagHelp()}, + "milestone/list": {Summary: "List milestones", Usage: []string{"hf milestone list --project <project-code> [--status <status>] [--order-by <due-date|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "milestone/get": {Summary: "Show a milestone by code", Usage: []string{"hf milestone get <milestone-code>"}, Flags: authFlagHelp()}, + "milestone/create": {Summary: "Create a milestone", Usage: []string{"hf milestone create --project <project-code> --title <title> [--desc <desc>] [--due <date>]"}, Flags: authFlagHelp()}, + "milestone/update": {Summary: "Update a milestone", Usage: []string{"hf milestone update <milestone-code> [--title <title>] [--desc <desc>] [--status <status>] [--due <date>]"}, Flags: authFlagHelp()}, + "milestone/delete": {Summary: "Delete a milestone", Usage: []string{"hf milestone delete <milestone-code>"}, Flags: authFlagHelp()}, + "milestone/progress": {Summary: "Show milestone progress", Usage: []string{"hf milestone progress <milestone-code>"}, Flags: authFlagHelp()}, + "task/list": {Summary: "List tasks", Usage: []string{"hf task list [--project <project-code>] [--milestone <milestone-code>] [--status <status>] [--taken-by <me|null|username>] [--due-today <true|false>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "task/get": {Summary: "Show a task by code", Usage: []string{"hf task get <task-code>"}, Flags: authFlagHelp()}, + "task/create": {Summary: "Create a task", Usage: []string{"hf task create --project <project-code> --title <title> [--milestone <milestone-code>] [--type <type>] [--priority <priority>] [--desc <desc>]"}, Flags: authFlagHelp()}, + "task/update": {Summary: "Update a task", Usage: []string{"hf task update <task-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>] [--assignee <username|null>]"}, Flags: authFlagHelp()}, + "task/transition": {Summary: "Transition a task to a new status", Usage: []string{"hf task transition <task-code> <status>"}, Flags: authFlagHelp()}, + "task/take": {Summary: "Assign a task to the current user", Usage: []string{"hf task take <task-code>"}, Flags: authFlagHelp()}, + "task/delete": {Summary: "Delete a task", Usage: []string{"hf task delete <task-code>"}, Flags: authFlagHelp()}, + "task/search": {Summary: "Search tasks", Usage: []string{"hf task search --query <text> [--project <project-code>] [--status <status>]"}, Flags: authFlagHelp()}, + "meeting/list": {Summary: "List meetings", Usage: []string{"hf meeting list [--project <project-code>] [--status <status>] [--order-by <created|due-date|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "meeting/get": {Summary: "Show a meeting by code", Usage: []string{"hf meeting get <meeting-code>"}, Flags: authFlagHelp()}, + "meeting/create": {Summary: "Create a meeting", Usage: []string{"hf meeting create --project <project-code> --title <title> [--milestone <milestone-code>] [--desc <desc>] [--time <datetime>]"}, Flags: authFlagHelp()}, + "meeting/update": {Summary: "Update a meeting", Usage: []string{"hf meeting update <meeting-code> [--title <title>] [--desc <desc>] [--status <status>] [--time <datetime>]"}, Flags: authFlagHelp()}, + "meeting/attend": {Summary: "Attend a meeting", Usage: []string{"hf meeting attend <meeting-code>"}, Flags: authFlagHelp()}, + "meeting/delete": {Summary: "Delete a meeting", Usage: []string{"hf meeting delete <meeting-code>"}, Flags: authFlagHelp()}, + "support/list": {Summary: "List support tickets", Usage: []string{"hf support list [--taken-by <me|null|username>] [--status <status>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "support/get": {Summary: "Show a support ticket by code", Usage: []string{"hf support get <support-code>"}, Flags: authFlagHelp()}, + "support/create": {Summary: "Create a support ticket", Usage: []string{"hf support create --title <title> [--project <project-code>] [--desc <desc>] [--priority <priority>]"}, Flags: authFlagHelp()}, + "support/update": {Summary: "Update a support ticket", Usage: []string{"hf support update <support-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>]"}, Flags: authFlagHelp()}, + "support/take": {Summary: "Assign a support ticket to the current user", Usage: []string{"hf support take <support-code>"}, Flags: authFlagHelp()}, + "support/transition": {Summary: "Transition a support ticket to a new status", Usage: []string{"hf support transition <support-code> <status>"}, Flags: authFlagHelp()}, + "support/delete": {Summary: "Delete a support ticket", Usage: []string{"hf support delete <support-code>"}, Flags: authFlagHelp()}, + "propose/list": {Summary: "List proposals", Usage: []string{"hf propose list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "propose/get": {Summary: "Show a proposal by code", Usage: []string{"hf propose get <propose-code>"}, Flags: authFlagHelp()}, + "propose/create": {Summary: "Create a proposal", Usage: []string{"hf propose create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()}, + "propose/update": {Summary: "Update a proposal", Usage: []string{"hf propose update <propose-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()}, + "propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()}, + "propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()}, + "propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()}, + "monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, Flags: authFlagHelp()}, + "monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()}, + "monitor/api-key": {Summary: "Manage monitor API keys", Usage: []string{"hf monitor api-key generate <identifier>", "hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()}, + } + + if group == "" { + spec, ok := specs[cmd] + return spec, ok + } + spec, ok := specs[group+"/"+cmd] + return spec, ok +} -- 2.49.1 From ebad3cd0d3da2919e5851e97c4f86c5617d817f1 Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 16:06:41 +0000 Subject: [PATCH 10/15] Refresh CLI status documentation --- .gitignore | 1 + README.md | 45 +++++++++++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 7f840a2..5fbc933 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ +hf dist/ coverage.out *.test diff --git a/README.md b/README.md index 0d82ddb..868b346 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands. cmd/hf/ CLI entrypoint internal/ client/ HTTP client wrapper for HarborForge API - commands/ Command implementations (version, health, config, auth helpers) + commands/ Command implementations config/ Config file resolution and management (.hf-config.json) - help/ Help and help-brief renderer + help/ Help and help-brief renderer with detailed leaf help mode/ Runtime mode detection (padded-cell vs manual) output/ Output formatting (human-readable, JSON, tables) passmgr/ pass_mgr integration for secret resolution @@ -60,19 +60,40 @@ internal/ ## Current Status -Implemented: +### Implemented + +**Foundation:** - Go module and binary entrypoint - Config file resolution relative to binary directory - Runtime mode detection (`pass_mgr` present/absent) -- Top-level and group/leaf help rendering system (`--help` / `--help-brief` / `not permitted` stubs) -- Detailed leaf help text for implemented commands, with padded-cell/manual auth flag differences -- Permission-aware command visibility via `/auth/me/permissions` when a token is available -- HTTP client wrapper +- HTTP client wrapper (GET/POST/PUT/PATCH/DELETE) - Output formatting (human-readable + `--json`) -- `hf version`, `hf health`, `hf config` - Auth token resolution (padded-cell + manual) -- Backend-aligned role/permission commands, including role-name lookup and permission-name↔id translation against current API routes -Planned: -- User, role, project, task, milestone, meeting, support, propose, monitor commands -- Rich per-command help/usage text beyond the current stub renderer +**Help system:** +- Top-level and group/leaf help rendering (`--help` / `--help-brief`) +- Permission-aware command visibility via `/auth/me/permissions` +- Detailed leaf help text for all commands, with padded-cell/manual auth flag differences +- `(not permitted)` rendering for unauthorized commands + +**Core commands:** +- `hf version`, `hf health`, `hf config` (show / `--url` / `--acc-mgr-token`) + +**Resource commands (all implemented with list/get/create/update/delete + special actions):** +- `hf user` — create, list, get, update, activate, deactivate, delete +- `hf role` — list, get, create, update, delete, set-permissions, add-permissions, remove-permissions +- `hf permission` — list +- `hf project` — list, get, create, update, delete, members, add-member, remove-member +- `hf milestone` — list, get, create, update, delete, progress +- `hf task` — list, get, create, update, transition, take, delete, search +- `hf meeting` — list, get, create, update, attend, delete +- `hf support` — list, get, create, update, take, transition, delete +- `hf propose` — list, get, create, update, accept, reject, reopen +- `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke) + +### Pending + +- Backend code-based endpoint support (some commands still use id-based API routes) +- Comment and worklog commands +- Cross-platform binary packaging / release pipeline +- Integration tests -- 2.49.1 From 1e8437d0b1b73b227f91cd93ea3d3a1ca4e50982 Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 16:37:47 +0000 Subject: [PATCH 11/15] Improve nested CLI help coverage --- README.md | 1 + cmd/hf/main.go | 69 +++++++++++++++++++++-- internal/help/leaf.go | 127 ++++++++++++++++++++++-------------------- 3 files changed, 132 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 868b346..befc87a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ internal/ - Top-level and group/leaf help rendering (`--help` / `--help-brief`) - Permission-aware command visibility via `/auth/me/permissions` - Detailed leaf help text for all commands, with padded-cell/manual auth flag differences +- Nested help coverage for `config`, `monitor server`, and `monitor api-key` subtrees - `(not permitted)` rendering for unauthorized commands **Core commands:** diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 09b3b43..cb0b04a 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -73,7 +73,7 @@ func handleLeafOrRun(name string, args []string, run func()) { } func handleConfig(args []string) { - if isHelpFlagOnly(args) { + if isLeafHelpFlagOnly(args) || isHelpFlagOnly(args) { if text, ok := help.RenderLeafHelp("config", "show"); ok { fmt.Print(text) return @@ -131,6 +131,17 @@ func handleGroup(group help.Group, args []string) { output.Errorf("unknown %s subcommand: %s", group.Name, args[0]) } + if group.Name == "monitor" && (sub.Name == "server" || sub.Name == "api-key") { + if len(args) == 1 { + handleMonitorCommand(sub.Name, nil) + return + } + if isHelpLikePath(args[1:]) || sub.Permitted { + handleMonitorCommand(sub.Name, args[1:]) + return + } + } + if len(args) > 1 && isLeafHelpFlagOnly(args[1:]) { if !sub.Permitted { fmt.Println(help.RenderNotPermitted(group.Name, sub.Name)) @@ -282,6 +293,16 @@ func isLeafHelpFlagOnly(args []string) bool { return len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "--help-brief") } +func isHelpLikePath(args []string) bool { + if len(args) == 0 { + return true + } + if len(args) == 1 { + return args[0] == "--help" || args[0] == "-h" || args[0] == "--help-brief" + } + return isLeafHelpFlagOnly(args[len(args)-1:]) +} + func findGroup(name string) (help.Group, bool) { for _, group := range help.CommandSurface() { if group.Name == name { @@ -742,12 +763,32 @@ func handleMonitorCommand(subCmd string, args []string) { } func handleMonitorServerCommand(args []string, tokenFlag string) { - if len(args) == 0 { - output.Error("usage: hf monitor server <list|get|create|delete> ...") + serverCommands := []help.Command{ + {Name: "list", Description: "List monitor servers", Permitted: true}, + {Name: "get", Description: "Show a monitor server by identifier", Permitted: true}, + {Name: "create", Description: "Create a monitor server", Permitted: true}, + {Name: "delete", Description: "Delete a monitor server", Permitted: true}, + } + + if len(args) == 0 || isHelpFlagOnly(args) { + fmt.Print(help.RenderGroupHelp("monitor server", serverCommands)) + return + } + if len(args) == 1 && args[0] == "--help-brief" { + fmt.Print(help.RenderGroupHelpBrief("monitor server", serverCommands)) + return } subCmd := args[0] remaining := args[1:] + if isLeafHelpFlagOnly(remaining) { + if text, ok := help.RenderLeafHelp("monitor/server", subCmd); ok { + fmt.Print(text) + return + } + fmt.Printf("hf monitor server %s\n", subCmd) + return + } switch subCmd { case "list": @@ -770,12 +811,30 @@ func handleMonitorServerCommand(args []string, tokenFlag string) { } func handleMonitorAPIKeyCommand(args []string, tokenFlag string) { - if len(args) == 0 { - output.Error("usage: hf monitor api-key <generate|revoke> <identifier>") + apiKeyCommands := []help.Command{ + {Name: "generate", Description: "Generate a monitor API key", Permitted: true}, + {Name: "revoke", Description: "Revoke a monitor API key", Permitted: true}, + } + + if len(args) == 0 || isHelpFlagOnly(args) { + fmt.Print(help.RenderGroupHelp("monitor api-key", apiKeyCommands)) + return + } + if len(args) == 1 && args[0] == "--help-brief" { + fmt.Print(help.RenderGroupHelpBrief("monitor api-key", apiKeyCommands)) + return } subCmd := args[0] remaining := args[1:] + if isLeafHelpFlagOnly(remaining) { + if text, ok := help.RenderLeafHelp("monitor/api-key", subCmd); ok { + fmt.Print(text) + return + } + fmt.Printf("hf monitor api-key %s\n", subCmd) + return + } switch subCmd { case "generate": diff --git a/internal/help/leaf.go b/internal/help/leaf.go index 6610f78..6a26baf 100644 --- a/internal/help/leaf.go +++ b/internal/help/leaf.go @@ -22,6 +22,7 @@ func RenderLeafHelp(group, cmd string) (string, bool) { var b strings.Builder name := strings.TrimSpace(strings.TrimSpace(group + " " + cmd)) + name = strings.ReplaceAll(name, "/", " ") b.WriteString(fmt.Sprintf("hf %s - %s\n", name, spec.Summary)) b.WriteString("\nUsage:\n") for _, line := range spec.Usage { @@ -107,66 +108,72 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) { "In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to pass_mgr.", }, }, - "user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()}, - "user/get": {Summary: "Show a user by username", Usage: []string{"hf user get <username>"}, Flags: authFlagHelp()}, - "user/update": {Summary: "Update a user", Usage: []string{"hf user update <username> [--email <email>] [--full-name <name>] [--pass <password>] [--active <true|false>]"}, Flags: authFlagHelp()}, - "user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()}, - "user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()}, - "user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()}, - "role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()}, - "role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()}, - "role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()}, - "role/update": {Summary: "Update a role", Usage: []string{"hf role update <role-name> [--desc <desc>]"}, Flags: authFlagHelp()}, - "role/delete": {Summary: "Delete a role", Usage: []string{"hf role delete <role-name>"}, Flags: authFlagHelp()}, - "role/set-permissions": {Summary: "Replace role permissions", Usage: []string{"hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, - "role/add-permissions": {Summary: "Add permissions to a role", Usage: []string{"hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, - "role/remove-permissions": {Summary: "Remove permissions from a role", Usage: []string{"hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, - "permission/list": {Summary: "List permissions", Usage: []string{"hf permission list"}, Flags: authFlagHelp()}, - "project/list": {Summary: "List projects", Usage: []string{"hf project list [--owner <username>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, - "project/get": {Summary: "Show a project by code", Usage: []string{"hf project get <project-code>"}, Flags: authFlagHelp()}, - "project/create": {Summary: "Create a project", Usage: []string{"hf project create --name <name> [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()}, - "project/update": {Summary: "Update a project", Usage: []string{"hf project update <project-code> [--name <name>] [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()}, - "project/delete": {Summary: "Delete a project", Usage: []string{"hf project delete <project-code>"}, Flags: authFlagHelp()}, - "project/members": {Summary: "List project members", Usage: []string{"hf project members <project-code>"}, Flags: authFlagHelp()}, - "project/add-member": {Summary: "Add a member to a project", Usage: []string{"hf project add-member <project-code> --user <username> --role <role-name>"}, Flags: authFlagHelp()}, - "project/remove-member": {Summary: "Remove a member from a project", Usage: []string{"hf project remove-member <project-code> --user <username>"}, Flags: authFlagHelp()}, - "milestone/list": {Summary: "List milestones", Usage: []string{"hf milestone list --project <project-code> [--status <status>] [--order-by <due-date|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, - "milestone/get": {Summary: "Show a milestone by code", Usage: []string{"hf milestone get <milestone-code>"}, Flags: authFlagHelp()}, - "milestone/create": {Summary: "Create a milestone", Usage: []string{"hf milestone create --project <project-code> --title <title> [--desc <desc>] [--due <date>]"}, Flags: authFlagHelp()}, - "milestone/update": {Summary: "Update a milestone", Usage: []string{"hf milestone update <milestone-code> [--title <title>] [--desc <desc>] [--status <status>] [--due <date>]"}, Flags: authFlagHelp()}, - "milestone/delete": {Summary: "Delete a milestone", Usage: []string{"hf milestone delete <milestone-code>"}, Flags: authFlagHelp()}, - "milestone/progress": {Summary: "Show milestone progress", Usage: []string{"hf milestone progress <milestone-code>"}, Flags: authFlagHelp()}, - "task/list": {Summary: "List tasks", Usage: []string{"hf task list [--project <project-code>] [--milestone <milestone-code>] [--status <status>] [--taken-by <me|null|username>] [--due-today <true|false>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, - "task/get": {Summary: "Show a task by code", Usage: []string{"hf task get <task-code>"}, Flags: authFlagHelp()}, - "task/create": {Summary: "Create a task", Usage: []string{"hf task create --project <project-code> --title <title> [--milestone <milestone-code>] [--type <type>] [--priority <priority>] [--desc <desc>]"}, Flags: authFlagHelp()}, - "task/update": {Summary: "Update a task", Usage: []string{"hf task update <task-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>] [--assignee <username|null>]"}, Flags: authFlagHelp()}, - "task/transition": {Summary: "Transition a task to a new status", Usage: []string{"hf task transition <task-code> <status>"}, Flags: authFlagHelp()}, - "task/take": {Summary: "Assign a task to the current user", Usage: []string{"hf task take <task-code>"}, Flags: authFlagHelp()}, - "task/delete": {Summary: "Delete a task", Usage: []string{"hf task delete <task-code>"}, Flags: authFlagHelp()}, - "task/search": {Summary: "Search tasks", Usage: []string{"hf task search --query <text> [--project <project-code>] [--status <status>]"}, Flags: authFlagHelp()}, - "meeting/list": {Summary: "List meetings", Usage: []string{"hf meeting list [--project <project-code>] [--status <status>] [--order-by <created|due-date|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, - "meeting/get": {Summary: "Show a meeting by code", Usage: []string{"hf meeting get <meeting-code>"}, Flags: authFlagHelp()}, - "meeting/create": {Summary: "Create a meeting", Usage: []string{"hf meeting create --project <project-code> --title <title> [--milestone <milestone-code>] [--desc <desc>] [--time <datetime>]"}, Flags: authFlagHelp()}, - "meeting/update": {Summary: "Update a meeting", Usage: []string{"hf meeting update <meeting-code> [--title <title>] [--desc <desc>] [--status <status>] [--time <datetime>]"}, Flags: authFlagHelp()}, - "meeting/attend": {Summary: "Attend a meeting", Usage: []string{"hf meeting attend <meeting-code>"}, Flags: authFlagHelp()}, - "meeting/delete": {Summary: "Delete a meeting", Usage: []string{"hf meeting delete <meeting-code>"}, Flags: authFlagHelp()}, - "support/list": {Summary: "List support tickets", Usage: []string{"hf support list [--taken-by <me|null|username>] [--status <status>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, - "support/get": {Summary: "Show a support ticket by code", Usage: []string{"hf support get <support-code>"}, Flags: authFlagHelp()}, - "support/create": {Summary: "Create a support ticket", Usage: []string{"hf support create --title <title> [--project <project-code>] [--desc <desc>] [--priority <priority>]"}, Flags: authFlagHelp()}, - "support/update": {Summary: "Update a support ticket", Usage: []string{"hf support update <support-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>]"}, Flags: authFlagHelp()}, - "support/take": {Summary: "Assign a support ticket to the current user", Usage: []string{"hf support take <support-code>"}, Flags: authFlagHelp()}, - "support/transition": {Summary: "Transition a support ticket to a new status", Usage: []string{"hf support transition <support-code> <status>"}, Flags: authFlagHelp()}, - "support/delete": {Summary: "Delete a support ticket", Usage: []string{"hf support delete <support-code>"}, Flags: authFlagHelp()}, - "propose/list": {Summary: "List proposals", Usage: []string{"hf propose list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, - "propose/get": {Summary: "Show a proposal by code", Usage: []string{"hf propose get <propose-code>"}, Flags: authFlagHelp()}, - "propose/create": {Summary: "Create a proposal", Usage: []string{"hf propose create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()}, - "propose/update": {Summary: "Update a proposal", Usage: []string{"hf propose update <propose-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()}, - "propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()}, - "propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()}, - "propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()}, - "monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, Flags: authFlagHelp()}, - "monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()}, - "monitor/api-key": {Summary: "Manage monitor API keys", Usage: []string{"hf monitor api-key generate <identifier>", "hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()}, + "user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()}, + "user/get": {Summary: "Show a user by username", Usage: []string{"hf user get <username>"}, Flags: authFlagHelp()}, + "user/update": {Summary: "Update a user", Usage: []string{"hf user update <username> [--email <email>] [--full-name <name>] [--pass <password>] [--active <true|false>]"}, Flags: authFlagHelp()}, + "user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()}, + "user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()}, + "user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()}, + "role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()}, + "role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()}, + "role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()}, + "role/update": {Summary: "Update a role", Usage: []string{"hf role update <role-name> [--desc <desc>]"}, Flags: authFlagHelp()}, + "role/delete": {Summary: "Delete a role", Usage: []string{"hf role delete <role-name>"}, Flags: authFlagHelp()}, + "role/set-permissions": {Summary: "Replace role permissions", Usage: []string{"hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, + "role/add-permissions": {Summary: "Add permissions to a role", Usage: []string{"hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, + "role/remove-permissions": {Summary: "Remove permissions from a role", Usage: []string{"hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()}, + "permission/list": {Summary: "List permissions", Usage: []string{"hf permission list"}, Flags: authFlagHelp()}, + "project/list": {Summary: "List projects", Usage: []string{"hf project list [--owner <username>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "project/get": {Summary: "Show a project by code", Usage: []string{"hf project get <project-code>"}, Flags: authFlagHelp()}, + "project/create": {Summary: "Create a project", Usage: []string{"hf project create --name <name> [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()}, + "project/update": {Summary: "Update a project", Usage: []string{"hf project update <project-code> [--name <name>] [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()}, + "project/delete": {Summary: "Delete a project", Usage: []string{"hf project delete <project-code>"}, Flags: authFlagHelp()}, + "project/members": {Summary: "List project members", Usage: []string{"hf project members <project-code>"}, Flags: authFlagHelp()}, + "project/add-member": {Summary: "Add a member to a project", Usage: []string{"hf project add-member <project-code> --user <username> --role <role-name>"}, Flags: authFlagHelp()}, + "project/remove-member": {Summary: "Remove a member from a project", Usage: []string{"hf project remove-member <project-code> --user <username>"}, Flags: authFlagHelp()}, + "milestone/list": {Summary: "List milestones", Usage: []string{"hf milestone list --project <project-code> [--status <status>] [--order-by <due-date|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "milestone/get": {Summary: "Show a milestone by code", Usage: []string{"hf milestone get <milestone-code>"}, Flags: authFlagHelp()}, + "milestone/create": {Summary: "Create a milestone", Usage: []string{"hf milestone create --project <project-code> --title <title> [--desc <desc>] [--due <date>]"}, Flags: authFlagHelp()}, + "milestone/update": {Summary: "Update a milestone", Usage: []string{"hf milestone update <milestone-code> [--title <title>] [--desc <desc>] [--status <status>] [--due <date>]"}, Flags: authFlagHelp()}, + "milestone/delete": {Summary: "Delete a milestone", Usage: []string{"hf milestone delete <milestone-code>"}, Flags: authFlagHelp()}, + "milestone/progress": {Summary: "Show milestone progress", Usage: []string{"hf milestone progress <milestone-code>"}, Flags: authFlagHelp()}, + "task/list": {Summary: "List tasks", Usage: []string{"hf task list [--project <project-code>] [--milestone <milestone-code>] [--status <status>] [--taken-by <me|null|username>] [--due-today <true|false>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "task/get": {Summary: "Show a task by code", Usage: []string{"hf task get <task-code>"}, Flags: authFlagHelp()}, + "task/create": {Summary: "Create a task", Usage: []string{"hf task create --project <project-code> --title <title> [--milestone <milestone-code>] [--type <type>] [--priority <priority>] [--desc <desc>]"}, Flags: authFlagHelp()}, + "task/update": {Summary: "Update a task", Usage: []string{"hf task update <task-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>] [--assignee <username|null>]"}, Flags: authFlagHelp()}, + "task/transition": {Summary: "Transition a task to a new status", Usage: []string{"hf task transition <task-code> <status>"}, Flags: authFlagHelp()}, + "task/take": {Summary: "Assign a task to the current user", Usage: []string{"hf task take <task-code>"}, Flags: authFlagHelp()}, + "task/delete": {Summary: "Delete a task", Usage: []string{"hf task delete <task-code>"}, Flags: authFlagHelp()}, + "task/search": {Summary: "Search tasks", Usage: []string{"hf task search --query <text> [--project <project-code>] [--status <status>]"}, Flags: authFlagHelp()}, + "meeting/list": {Summary: "List meetings", Usage: []string{"hf meeting list [--project <project-code>] [--status <status>] [--order-by <created|due-date|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "meeting/get": {Summary: "Show a meeting by code", Usage: []string{"hf meeting get <meeting-code>"}, Flags: authFlagHelp()}, + "meeting/create": {Summary: "Create a meeting", Usage: []string{"hf meeting create --project <project-code> --title <title> [--milestone <milestone-code>] [--desc <desc>] [--time <datetime>]"}, Flags: authFlagHelp()}, + "meeting/update": {Summary: "Update a meeting", Usage: []string{"hf meeting update <meeting-code> [--title <title>] [--desc <desc>] [--status <status>] [--time <datetime>]"}, Flags: authFlagHelp()}, + "meeting/attend": {Summary: "Attend a meeting", Usage: []string{"hf meeting attend <meeting-code>"}, Flags: authFlagHelp()}, + "meeting/delete": {Summary: "Delete a meeting", Usage: []string{"hf meeting delete <meeting-code>"}, Flags: authFlagHelp()}, + "support/list": {Summary: "List support tickets", Usage: []string{"hf support list [--taken-by <me|null|username>] [--status <status>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "support/get": {Summary: "Show a support ticket by code", Usage: []string{"hf support get <support-code>"}, Flags: authFlagHelp()}, + "support/create": {Summary: "Create a support ticket", Usage: []string{"hf support create --title <title> [--project <project-code>] [--desc <desc>] [--priority <priority>]"}, Flags: authFlagHelp()}, + "support/update": {Summary: "Update a support ticket", Usage: []string{"hf support update <support-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>]"}, Flags: authFlagHelp()}, + "support/take": {Summary: "Assign a support ticket to the current user", Usage: []string{"hf support take <support-code>"}, Flags: authFlagHelp()}, + "support/transition": {Summary: "Transition a support ticket to a new status", Usage: []string{"hf support transition <support-code> <status>"}, Flags: authFlagHelp()}, + "support/delete": {Summary: "Delete a support ticket", Usage: []string{"hf support delete <support-code>"}, Flags: authFlagHelp()}, + "propose/list": {Summary: "List proposals", Usage: []string{"hf propose list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "propose/get": {Summary: "Show a proposal by code", Usage: []string{"hf propose get <propose-code>"}, Flags: authFlagHelp()}, + "propose/create": {Summary: "Create a proposal", Usage: []string{"hf propose create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()}, + "propose/update": {Summary: "Update a proposal", Usage: []string{"hf propose update <propose-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()}, + "propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()}, + "propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()}, + "propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()}, + "monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, Flags: authFlagHelp()}, + "monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()}, + "monitor/server/list": {Summary: "List monitor servers", Usage: []string{"hf monitor server list"}, Flags: authFlagHelp()}, + "monitor/server/get": {Summary: "Show a monitor server by identifier", Usage: []string{"hf monitor server get <identifier>"}, Flags: authFlagHelp()}, + "monitor/server/create": {Summary: "Create a monitor server", Usage: []string{"hf monitor server create --identifier <identifier> [--name <display-name>]"}, Flags: authFlagHelp()}, + "monitor/server/delete": {Summary: "Delete a monitor server", Usage: []string{"hf monitor server delete <identifier>"}, Flags: authFlagHelp()}, + "monitor/api-key": {Summary: "Manage monitor API keys", Usage: []string{"hf monitor api-key generate <identifier>", "hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()}, + "monitor/api-key/generate": {Summary: "Generate a monitor API key", Usage: []string{"hf monitor api-key generate <identifier>"}, Flags: authFlagHelp()}, + "monitor/api-key/revoke": {Summary: "Revoke a monitor API key", Usage: []string{"hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()}, } if group == "" { -- 2.49.1 From 9b3edc0ede4a3ba84c121a1c69fa5c1bd1b9b995 Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 17:11:20 +0000 Subject: [PATCH 12/15] Add comment and worklog CLI commands --- README.md | 3 +- cmd/hf/main.go | 132 ++++++++++++++++++++++++++++++++++ internal/commands/comment.go | 136 +++++++++++++++++++++++++++++++++++ internal/commands/worklog.go | 124 ++++++++++++++++++++++++++++++++ internal/help/leaf.go | 4 ++ internal/help/surface.go | 16 +++++ 6 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 internal/commands/comment.go create mode 100644 internal/commands/worklog.go diff --git a/README.md b/README.md index befc87a..9c944be 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,12 @@ internal/ - `hf meeting` — list, get, create, update, attend, delete - `hf support` — list, get, create, update, take, transition, delete - `hf propose` — list, get, create, update, accept, reject, reopen +- `hf comment` — add, list +- `hf worklog` — add, list - `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke) ### Pending - Backend code-based endpoint support (some commands still use id-based API routes) -- Comment and worklog commands - Cross-platform binary packaging / release pipeline - Integration tests diff --git a/cmd/hf/main.go b/cmd/hf/main.go index cb0b04a..32bbf79 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -190,6 +190,12 @@ func handleGroup(group help.Group, args []string) { case "propose": handleProposeCommand(sub.Name, remaining) return + case "comment": + handleCommentCommand(sub.Name, remaining) + return + case "worklog": + handleWorklogCommand(sub.Name, remaining) + return case "monitor": handleMonitorCommand(sub.Name, remaining) return @@ -735,6 +741,132 @@ func handleProposeCommand(subCmd string, args []string) { } } +func handleCommentCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "add": + taskCode, content := "", "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + case "--content": + if i+1 < len(filtered) { + i++ + content = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunCommentAdd(taskCode, content, tokenFlag) + case "list": + taskCode := "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunCommentList(taskCode, tokenFlag) + default: + output.Errorf("hf comment %s is not implemented yet", subCmd) + } +} + +func handleWorklogCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "add": + taskCode, desc, date := "", "", "" + hours := 0.0 + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + case "--hours": + if i+1 < len(filtered) { + i++ + if _, err := fmt.Sscanf(filtered[i], "%f", &hours); err != nil { + output.Error("--hours requires a numeric value") + } + } + case "--desc": + if i+1 < len(filtered) { + i++ + desc = filtered[i] + } + case "--date": + if i+1 < len(filtered) { + i++ + date = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunWorklogAdd(taskCode, hours, desc, date, tokenFlag) + case "list": + taskCode, username := "", "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--task": + if i+1 < len(filtered) { + i++ + taskCode = filtered[i] + } + case "--user": + if i+1 < len(filtered) { + i++ + username = filtered[i] + } + default: + output.Errorf("unknown flag: %s", filtered[i]) + } + } + commands.RunWorklogList(taskCode, username, tokenFlag) + default: + output.Errorf("hf worklog %s is not implemented yet", subCmd) + } +} + func handleMonitorCommand(subCmd string, args []string) { tokenFlag := "" var filtered []string diff --git a/internal/commands/comment.go b/internal/commands/comment.go new file mode 100644 index 0000000..ce51411 --- /dev/null +++ b/internal/commands/comment.go @@ -0,0 +1,136 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +type commentResponse struct { + ID int `json:"id"` + TaskID int `json:"task_id"` + AuthorID int `json:"author_id"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt *string `json:"updated_at"` +} + +func RunCommentAdd(taskCode, content, tokenFlag string) { + if taskCode == "" || content == "" { + output.Error("usage: hf comment add --task <task-code> --content <text>") + } + + c := newAuthedClient(tokenFlag) + taskID := resolveTaskID(c, taskCode) + me := currentUser(c) + + payload := map[string]interface{}{ + "task_id": taskID, + "author_id": me.ID, + "content": content, + } + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + data, err := c.Post("/comments", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add comment: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp commentResponse + if err := json.Unmarshal(data, &resp); err != nil { + output.Errorf("cannot parse response: %v", err) + } + fmt.Printf("comment added to %s: #%d\n", taskCode, resp.ID) +} + +func RunCommentList(taskCode, tokenFlag string) { + if taskCode == "" { + output.Error("usage: hf comment list --task <task-code>") + } + + c := newAuthedClient(tokenFlag) + taskID := resolveTaskID(c, taskCode) + data, err := c.Get(fmt.Sprintf("/tasks/%d/comments", taskID)) + if err != nil { + output.Errorf("failed to list comments: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var comments []commentResponse + if err := json.Unmarshal(data, &comments); err != nil { + output.Errorf("cannot parse comment list: %v", err) + } + + headers := []string{"ID", "AUTHOR", "CREATED", "CONTENT"} + var rows [][]string + for _, item := range comments { + content := item.Content + if len(content) > 60 { + content = content[:57] + "..." + } + rows = append(rows, []string{fmt.Sprintf("%d", item.ID), fmt.Sprintf("%d", item.AuthorID), item.CreatedAt, content}) + } + output.PrintTable(headers, rows) +} + +func newAuthedClient(tokenFlag string) *client.Client { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + return client.New(cfg.BaseURL, token) +} + +type authMeResponse struct { + ID int `json:"id"` + Username string `json:"username"` +} + +func currentUser(c *client.Client) authMeResponse { + data, err := c.Get("/auth/me") + if err != nil { + output.Errorf("failed to resolve current user: %v", err) + } + var me authMeResponse + if err := json.Unmarshal(data, &me); err != nil { + output.Errorf("cannot parse current user: %v", err) + } + return me +} + +func resolveTaskID(c *client.Client, taskCode string) int { + data, err := c.Get("/tasks/" + taskCode) + if err != nil { + output.Errorf("failed to resolve task %s: %v", taskCode, err) + } + var task taskResponse + if err := json.Unmarshal(data, &task); err != nil { + output.Errorf("cannot parse task %s: %v", taskCode, err) + } + return task.ID +} diff --git a/internal/commands/worklog.go b/internal/commands/worklog.go new file mode 100644 index 0000000..fa51328 --- /dev/null +++ b/internal/commands/worklog.go @@ -0,0 +1,124 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +type worklogResponse struct { + ID int `json:"id"` + TaskID int `json:"task_id"` + UserID int `json:"user_id"` + Hours float64 `json:"hours"` + Description *string `json:"description"` + LoggedDate string `json:"logged_date"` + CreatedAt string `json:"created_at"` +} + +func RunWorklogAdd(taskCode string, hours float64, desc, date, tokenFlag string) { + if taskCode == "" || hours <= 0 { + output.Error("usage: hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]") + } + + c := newAuthedClient(tokenFlag) + taskID := resolveTaskID(c, taskCode) + me := currentUser(c) + + payload := map[string]interface{}{ + "task_id": taskID, + "user_id": me.ID, + "hours": hours, + } + if desc != "" { + payload["description"] = desc + } + if date != "" { + payload["logged_date"] = date + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + data, err := c.Post("/worklogs", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to add worklog: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp worklogResponse + if err := json.Unmarshal(data, &resp); err != nil { + output.Errorf("cannot parse response: %v", err) + } + fmt.Printf("worklog added to %s: #%d (%.2fh)\n", taskCode, resp.ID, resp.Hours) +} + +func RunWorklogList(taskCode, username, tokenFlag string) { + if taskCode == "" && username == "" { + output.Error("usage: hf worklog list [--task <task-code>] [--user <username>]") + } + if taskCode != "" && username != "" { + output.Error("choose only one of --task <task-code> or --user <username>") + } + + c := newAuthedClient(tokenFlag) + path := "" + if taskCode != "" { + taskID := resolveTaskID(c, taskCode) + path = fmt.Sprintf("/tasks/%d/worklogs", taskID) + } else { + path = fmt.Sprintf("/users/%s/worklogs", username) + } + + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to list worklogs: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var logs []worklogResponse + if err := json.Unmarshal(data, &logs); err != nil { + output.Errorf("cannot parse worklog list: %v", err) + } + + headers := []string{"ID", "TASK", "USER", "HOURS", "DATE", "DESCRIPTION"} + var rows [][]string + for _, item := range logs { + desc := "" + if item.Description != nil { + desc = *item.Description + } + if len(desc) > 40 { + desc = desc[:37] + "..." + } + rows = append(rows, []string{ + fmt.Sprintf("%d", item.ID), + fmt.Sprintf("%d", item.TaskID), + fmt.Sprintf("%d", item.UserID), + fmt.Sprintf("%.2f", item.Hours), + item.LoggedDate, + desc, + }) + } + output.PrintTable(headers, rows) +} diff --git a/internal/help/leaf.go b/internal/help/leaf.go index 6a26baf..1f157ce 100644 --- a/internal/help/leaf.go +++ b/internal/help/leaf.go @@ -165,6 +165,10 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) { "propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()}, "propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()}, "propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()}, + "comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()}, + "comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()}, + "worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, + "worklog/list": {Summary: "List work logs by task or user", Usage: []string{"hf worklog list [--task <task-code>] [--user <username>]"}, Flags: authFlagHelp()}, "monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, Flags: authFlagHelp()}, "monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()}, "monitor/server/list": {Summary: "List monitor servers", Usage: []string{"hf monitor server list"}, Flags: authFlagHelp()}, diff --git a/internal/help/surface.go b/internal/help/surface.go index 89bf7ec..4085daf 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -138,6 +138,22 @@ func CommandSurface() []Group { {Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")}, }, }, + { + Name: "comment", + Description: "Manage task comments", + SubCommands: []Command{ + {Name: "add", Description: "Add a comment to a task", Permitted: has(perms, "task.read")}, + {Name: "list", Description: "List comments for a task", Permitted: has(perms, "task.read")}, + }, + }, + { + Name: "worklog", + Description: "Manage work logs", + SubCommands: []Command{ + {Name: "add", Description: "Add a work log entry", Permitted: has(perms, "task.read")}, + {Name: "list", Description: "List work logs by task or user", Permitted: has(perms, "task.read")}, + }, + }, { Name: "monitor", Description: "Monitor servers and API keys", -- 2.49.1 From cc649a7fe27d0f2bc3b1790d0fa91931b35d0a02 Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 17:39:26 +0000 Subject: [PATCH 13/15] Document CLI output and exit conventions --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 9c944be..b2531d8 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,37 @@ internal/ - **Padded-cell mode**: When `pass_mgr` is available, auth tokens are resolved automatically. Manual `--token` flags are rejected. - **Manual mode**: When `pass_mgr` is not available, `--token` must be provided explicitly to authenticated commands. +## Output / Error Contract + +### Human-readable mode + +Default output is human-readable: +- list commands render simple tables +- get/detail commands render key-value output +- empty lists render `(no results)` + +### JSON mode + +`--json` can be supplied globally and produces structured JSON on stdout. + +Current contract: +- success payloads go to **stdout** as JSON +- errors go to **stderr** as plain text +- the CLI does **not** wrap successful payloads in a universal envelope yet +- list/get payloads preserve canonical code-bearing fields whenever the backend already returns them + +This is intentionally simple so agents can pipe `hf ... --json` into other tooling without first stripping banners or mixed text. + +### Exit / stderr conventions + +Current CLI convention is: +- exit `0` on success +- exit `1` on command/validation/runtime errors +- user-facing errors are written to **stderr** +- success output is written to **stdout** + +There is not yet a finer-grained exit-code taxonomy; callers should currently treat any non-zero exit as failure. + ## Current Status ### Implemented -- 2.49.1 From 49b54beace1916fd99e5d841a316efebfd3b30fb Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 18:44:26 +0000 Subject: [PATCH 14/15] Add CLI install documentation --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/README.md b/README.md index b2531d8..e62e1f3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,67 @@ go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands. ./bin/hf version --json ``` +## Install + +### Local install into a user bin directory + +```bash +mkdir -p "$HOME/.local/bin" +go build -o "$HOME/.local/bin/hf" ./cmd/hf +chmod +x "$HOME/.local/bin/hf" +``` + +Make sure `~/.local/bin` is on `PATH` before invoking `hf` directly. + +### OpenClaw profile install target + +The planned OpenClaw plugin installer flow will place the binary at: + +```text +~/.openclaw/bin/hf +``` + +Until that installer support lands, the equivalent manual install is: + +```bash +mkdir -p "$HOME/.openclaw/bin" +go build -o "$HOME/.openclaw/bin/hf" ./cmd/hf +chmod +x "$HOME/.openclaw/bin/hf" +``` + +### Config location + +`hf` resolves `.hf-config.json` relative to the binary directory, not the current working directory. + +Examples: +- if the binary is `~/.local/bin/hf`, config lives at `~/.local/bin/.hf-config.json` +- if the binary is `~/.openclaw/bin/hf`, config lives at `~/.openclaw/bin/.hf-config.json` + +This matters when testing multiple copies of the CLI side by side. + +### Quick start after install + +```bash +hf config --url http://127.0.0.1:8000 +hf --help-brief +hf health +``` + +### Auth modes after install + +- **Padded-cell mode** (`pass_mgr` available): run commands directly and let `hf` resolve secrets automatically. +- **Manual mode** (`pass_mgr` unavailable): pass `--token` to authenticated commands. + +Examples: + +```bash +# padded-cell mode +hf task list + +# manual mode +hf task list --token "$HF_TOKEN" +``` + ## Package Layout ```text -- 2.49.1 From b3063733a9f8130ef13de24297c1daa98cf7cce2 Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Sat, 21 Mar 2026 20:32:28 +0000 Subject: [PATCH 15/15] Add release build targets for hf --- Makefile | 35 +++++++++++++++++++++++++++++++++++ README.md | 45 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8636db1 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +VERSION ?= dev +BINARY := hf +MAIN_PKG := ./cmd/hf +LDFLAGS := -X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=$(VERSION) +DIST_DIR := dist +RELEASE_TARGETS := \ + linux/amd64 \ + linux/arm64 \ + darwin/amd64 \ + darwin/arm64 \ + windows/amd64 + +.PHONY: build clean release release-all + +build: + mkdir -p bin + go build -ldflags "$(LDFLAGS)" -o ./bin/$(BINARY) $(MAIN_PKG) + +clean: + rm -rf ./bin/$(BINARY) ./$(DIST_DIR) + +release: clean + mkdir -p $(DIST_DIR) + @set -e; \ + for target in $(RELEASE_TARGETS); do \ + os=$${target%/*}; \ + arch=$${target#*/}; \ + ext=""; \ + if [ "$$os" = "windows" ]; then ext=".exe"; fi; \ + out="$(DIST_DIR)/$(BINARY)_$(VERSION)_$${os}_$${arch}$${ext}"; \ + echo "==> Building $$out"; \ + GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o "$$out" $(MAIN_PKG); \ + done + +release-all: release diff --git a/README.md b/README.md index e62e1f3..2bcf630 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,24 @@ go build -o ./bin/hf ./cmd/hf ``` +Or use the bundled Makefile: + +```bash +make build +``` + To set the version at build time: ```bash go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=1.0.0" -o ./bin/hf ./cmd/hf ``` +The Makefile versioned equivalent is: + +```bash +make build VERSION=1.0.0 +``` + ## Run ```bash @@ -53,13 +65,13 @@ Make sure `~/.local/bin` is on `PATH` before invoking `hf` directly. ### OpenClaw profile install target -The planned OpenClaw plugin installer flow will place the binary at: +The OpenClaw plugin installer flow places the binary at: ```text ~/.openclaw/bin/hf ``` -Until that installer support lands, the equivalent manual install is: +If you want the equivalent manual install: ```bash mkdir -p "$HOME/.openclaw/bin" @@ -100,6 +112,33 @@ hf task list hf task list --token "$HF_TOKEN" ``` +## Release Packaging + +Cross-platform release builds are available through the Makefile: + +```bash +make release VERSION=1.0.0 +``` + +This produces versioned artifacts in `dist/` using a stable naming pattern: + +```text +hf_<version>_<os>_<arch> +hf_<version>_<os>_<arch>.exe # Windows +``` + +Current release targets: +- `linux/amd64` +- `linux/arm64` +- `darwin/amd64` +- `darwin/arm64` +- `windows/amd64` + +Examples: +- `dist/hf_1.0.0_linux_amd64` +- `dist/hf_1.0.0_darwin_arm64` +- `dist/hf_1.0.0_windows_amd64.exe` + ## Package Layout ```text @@ -189,5 +228,5 @@ There is not yet a finer-grained exit-code taxonomy; callers should currently tr ### Pending - Backend code-based endpoint support (some commands still use id-based API routes) -- Cross-platform binary packaging / release pipeline +- Release automation beyond local `make release` packaging (checksums / archives / CI publishing) - Integration tests -- 2.49.1