27 Commits

Author SHA1 Message Date
4df6e1bd5f feat(knowledge-base): wrap KnowledgeBase API in the CLI
Add `hf knowledge-base` group: list/get/tree/topics, create/update/delete,
link/unlink to projects, and add/update/delete for topics, categories and
facts. Mirrors the project command style (flag parsing, JSON/table output,
token resolution). Registered in the dispatcher and the help surface, gated
on the knowledge-base.* permissions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:03:22 +01:00
h z
c0ab087436 Merge pull request 'fix(cli): assign-schedule-type dispatch at top level' (#8) from fix/assign-schedule-type-dispatch into main 2026-05-29 07:47:28 +00:00
3edabb72ba fix(cli): assign-schedule-type dispatch at top level
`assign-schedule-type` is registered in CommandSurface() as a Group, so
the default branch of main()'s switch picks it up via findGroup() and
hands it to handleGroup. handleGroup then treats args[0] (the agent-id)
as a subcommand name, fails findSubCommand, and errors:

    unknown assign-schedule-type subcommand: <agent-id>

The group has no subcommands — it's a leaf — so the call never reaches
handleAssignScheduleType. Add an explicit top-level case before the
default branch so the leaf bypasses the group dispatcher.

Pre-fix repro:
    $ AGENT_ID=ard hf assign-schedule-type analyst1 standard
    unknown assign-schedule-type subcommand: analyst1

Post-fix:
    $ AGENT_ID=ard CLAW_IDENTIFIER=server-t2 hf assign-schedule-type analyst1 standard
    Assigned schedule type 'standard' to agent 'analyst1'

Surfaced during recruitment workflow Step 5 on prod (sherlock/agent-resource-director).
2026-05-29 08:46:28 +01:00
h z
1c9e90b033 Merge pull request #7 2026-05-26 11:48:21 +00:00
2176383729 fix(cli): send api-keys via X-API-Key in client.New + help surface
passmgr.GetToken returns an api-key in padded-cell mode (provisioned by
scripts/provision-hf-accounts.sh via 'hf user reset-apikey'), but every
call site funneled that through client.New which sent it as a
'Authorization: Bearer <hex>'. The HF backend's HTTPBearer middleware
expects JWT shape there and rejects hex strings as 'Could not validate
credentials'. The d2b83ad backend fix added a Bearer-fallback that tries
the value as an api-key, which masked the issue against current prod;
older backends or any future change in that fallback still 401.

Two changes:
- client.New auto-detects shape: 'eyJ'-prefix + two dots == JWT (Bearer),
  anything else == api-key (X-API-Key). Empty token sets neither header.
- internal/help/surface.go's loadPermissionState (called by hf --help
  introspection) switches to client.NewWithAPIKey explicitly so the
  command-discovery path doesn't depend on the heuristic at all. When
  that path failed silently (Known:false), agents would see only the
  always-permitted commands ('user.*', 'agent.status', 'config',
  'health', 'version') and conclude they had no project permission.

Adds internal/client/client_test.go covering both header paths plus
empty-token, isLikelyJWT cases, and NewWithAPIKey precedence.

Verified end-to-end in sim against a rebuilt hf-backend matching prod
(commit d2b83ad): cli with --token <api-key> sends X-Api-Key header,
backend returns 200 on /projects + /auth/me/permissions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:43:04 +01:00
a42ba6f880 fix(cli): gate hf project create on project.create (was project.write)
surface.go declared project/create as Permitted: has(perms, "project.write"),
but the backend now (and the user-facing role editor's intent) uses
`project.create` as the dedicated create gate. Switching CLI and backend
to agree on the same perm so a role granted just `project.create` (e.g.
mgr in the new seed) can run `hf project create` without needing the
broader project.write.

Companion change to HarborForge.Backend@HEAD which adds project.create to
DEFAULT_PERMISSIONS, gives it to mgr by default, and rewrites the
POST /projects gate to consult it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:09:41 +01:00
h z
b0f4aa286b Merge pull request 'feat(cli): hf user create --agent-id/--claw-identifier + hf user bind-agent' (#6) from feat/user-bind-agent into main 2026-05-22 19:02:03 +00:00
hanghang zhang
46d928782b feat(cli): hf user create --agent-id/--claw-identifier + hf user bind-agent
Implements NEXT_WAVE_DEV_DIRECTION.md §7.3 (was 4 lines of spec, never
shipped). Backend's POST /users has accepted agent_id+claw_identifier
since BE-CAL-003 but the cli never sent them, so historically every
agent user (zhi/lyn/mirror/sherlock/orion/nav on prod today) was
created with only the user row — agents table left empty, and all
downstream calendar/heartbeat/schedule-type flows that go through
_require_agent() returned 404.

## hf user create — new flags

  --agent-id <id>
  --claw-identifier <id>

Both required together (matches backend invariant). Either can come
from pcexec env: AGENT_ID env for agent-id, `openclaw config get
plugins.harbor-forge.identifier` for claw-identifier. Partial pair is
treated as "neither" so plain user creation (no binding intended) still
works without a 400.

## hf user bind-agent <username> — NEW subcommand

Backfills agents row for an existing user. PATCH
/users/{username}/bind-agent. Same accept --agent-id/--claw-identifier
flags + pcexec env fallback. requireBoth=true here — fail loudly if
the pair can't be resolved since the whole command is the binding.

## Wiring

- userCreatePayload gains AgentID + ClawIdentifier omitempty fields
- new userBindAgentPayload struct (both required)
- resolveAgentBinding helper shared by both commands
- main.go user create case parses --agent-id/--claw-identifier;
  new user bind-agent case parses positional username + the same flags
- surface.go lists bind-agent so `hf user` and `hf --help` show it

Build: clean. Smoke-tested both subcommand usage strings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:01:37 +01:00
h z
e99b12ef08 Merge pull request 'feat: add 'hf agent status' wrapper' (#5) from feat/agent-status-cli into main 2026-05-22 18:08:57 +00:00
hanghang zhang
6ace6f2594 feat(cli): add 'hf agent status' wrapper for POST /calendar/agent/status
The plan-schedule workflow needs to report agent runtime status
(idle/busy/on_call/exhausted/offline) at the end of planning, but the
cli had no wrapper for this — workflows were dropping inline curl in
the middle of their procedure to hit the backend.

This adds 'hf agent status --set <status> [--reason ...] [--recovery-at ...]'.
The endpoint identifies the agent purely from X-Agent-ID + X-Claw-Identifier
headers (no token), so the cli reads AGENT_ID from env and falls back
to hostname() for CLAW_IDENTIFIER if it isn't set — same convention
the openclaw plugin uses. Refuses to send if AGENT_ID env is missing,
since this only makes sense from a pcexec/agent runtime context.

Surface entry added so 'hf --help' lists it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:08:27 +01:00
8dd58bad43 Merge docs/readme-refresh into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:55 +01:00
7ed99d7347 docs: refresh README — accuracy pass + HarborForge platform context
Verified against current code; fixed stale/inaccurate sections and
documented previously-undocumented features/flags/endpoints. Added a
"Part of the HarborForge platform" reference and role/port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:50:00 +01:00
h z
34a5512009 Merge pull request 'feat: schedule-type CLI commands' (#4) from zhi-2026-04-18 into main
Reviewed-on: #4
2026-05-01 07:25:43 +00:00
h z
ce532bdf15 Merge branch 'main' into zhi-2026-04-18 2026-05-01 07:25:35 +00:00
zhi
dbc599171f feat: schedule-type CLI commands
- hf schedule-type list
- hf schedule-type create <name> --work <from>-<to> --entertainment <from>-<to>
- hf schedule-type delete <id>
- hf assign-schedule-type <agent-id> <schedule-type-name>

Requires schedule_type.read / schedule_type.manage permissions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:25:39 +00:00
f1ebc52cca fix: allow reset-apikey command without user.manage permission
The reset-apikey command has its own auth mechanism via --acc-mgr-token,
so it should not be gated by permission introspection. This matches the
behavior of "user create" which is also Permitted: true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:32:30 +01:00
h z
de0ea39b2a Merge pull request 'dev-2026-03-29' (#3) from dev-2026-03-29 into main
Reviewed-on: #3
2026-04-16 21:21:32 +00:00
6dae490257 refactor: rename pass_mgr to secret-mgr
The secret manager binary was renamed from pass_mgr to secret-mgr.
Update all references in CLI code, mode detection, and help text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
53b5b88fc2 feat: user reset-apikey supports acc-mgr-token auth
Allows reset-apikey to use --acc-mgr-token or auto-resolve from
secret-mgr in padded-cell mode, enabling API key provisioning
without an existing user Bearer token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
6252039fc5 feat: add user reset-apikey command
Adds `hf user reset-apikey <username>` to regenerate a user API key.
Requires user.manage permission. Returns the new key (shown once only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
h z
cd22642472 Merge pull request 'HarborForge.Cli: dev-2026-03-29 -> main' (#2) from dev-2026-03-29 into main
Reviewed-on: #2
2026-04-05 22:08:34 +00:00
5ac90408f3 feat: support discord id account updates 2026-04-04 20:16:59 +00:00
ad0e123666 fix: send account-manager token as x-api-key 2026-04-03 19:12:34 +00:00
e2177521e0 feat: switch cli indexing to code-first identifiers 2026-04-03 16:25:11 +00:00
84150df4d5 fix: align cli routes with backend routers 2026-04-03 13:58:15 +00:00
b287b1ff17 fix: align health endpoint with backend 2026-04-03 13:45:36 +00:00
zhi
0280f2c327 TEST-CLI-CAL-001 TEST-CLI-PR-001 add CLI integration tests 2026-04-01 12:03:24 +00:00
21 changed files with 3194 additions and 94 deletions

View File

@@ -1,6 +1,8 @@
# HarborForge.Cli
`HarborForge.Cli` is the Go-based `hf` binary for HarborForge.
`HarborForge.Cli` is the Go-based `hf` command-line client for HarborForge.
Part of the [HarborForge](../README.md) platform. `hf` is a thin, scriptable client over the `HarborForge.Backend` REST API (default `http://127.0.0.1:8000`). It is permission-aware (command visibility derives from the caller's backend permissions) and supports both automatic secret resolution (padded-cell mode) and explicit `--token` auth (manual mode).
## Build
@@ -205,14 +207,15 @@ There is not yet a finer-grained exit-code taxonomy; callers should currently tr
- 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
- Nested help coverage for `config`, `monitor server`, `monitor api-key`, and `proposal essential` subtrees
- `(not permitted)` rendering for unauthorized commands
**Core commands:**
- `hf version`, `hf health`, `hf config` (show / `--url` / `--acc-mgr-token`)
- `hf update-discord-id <username> [discord-id]` — top-level convenience command
**Resource commands (all implemented with list/get/create/update/delete + special actions):**
- `hf user` — create, list, get, update, activate, deactivate, delete
- `hf user` — create, list, get, update, activate, deactivate, delete, reset-apikey
- `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
@@ -220,7 +223,9 @@ There is not yet a finer-grained exit-code taxonomy; callers should currently tr
- `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 proposal` (alias: `hf propose`) — list, get, create, update, accept, reject, reopen
- `hf proposal essential` — list, create, update, delete
- `hf calendar` — schedule, show, edit, cancel, date-list, plan-schedule, plan-list, plan-edit, plan-cancel
- `hf comment` — add, list
- `hf worklog` — add, list
- `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke)
@@ -229,4 +234,4 @@ There is not yet a finer-grained exit-code taxonomy; callers should currently tr
- Backend code-based endpoint support (some commands still use id-based API routes)
- Release automation beyond local `make release` packaging (checksums / archives / CI publishing)
- Integration tests
- Broader test coverage (unit tests exist for the calendar and proposal commands; end-to-end coverage is still partial)

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/help"
@@ -32,6 +33,46 @@ func main() {
handleLeafOrRun("health", args[1:], commands.RunHealth)
case "config":
handleConfig(args[1:])
case "update-discord-id":
tokenFlag := ""
var filtered []string
for i := 1; i < len(args); i++ {
switch args[i] {
case "--token":
if i+1 < len(args) {
i++
tokenFlag = args[i]
}
default:
filtered = append(filtered, args[i])
}
}
if len(filtered) < 1 {
output.Error("usage: hf update-discord-id <username> [discord-id]")
}
discordID := ""
if len(filtered) >= 2 {
discordID = filtered[1]
}
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
case "agent":
// `hf agent <sub>` — currently only `status` is implemented (wraps
// `POST /calendar/agent/status`, identifies caller via
// AGENT_ID/CLAW_IDENTIFIER env, no token needed).
if len(args) < 2 {
output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline>")
}
switch args[1] {
case "status":
commands.RunAgentStatus(args[2:])
default:
output.Errorf("unknown agent subcommand: %s", args[1])
}
case "assign-schedule-type":
// Leaf command (no subcommands) — args are <agent-id> <type-name>.
// Must dispatch at top level because handleGroup treats args[0] as
// a subcommand name and would error "unknown ... subcommand: <agent-id>".
handleAssignScheduleType(args[1:])
default:
if group, ok := findGroup(args[0]); ok {
handleGroup(group, args[1:])
@@ -175,6 +216,9 @@ func handleGroup(group help.Group, args []string) {
case "project":
handleProjectCommand(sub.Name, remaining)
return
case "knowledge-base":
handleKnowledgeBaseCommand(sub.Name, remaining)
return
case "milestone":
handleMilestoneCommand(sub.Name, remaining)
return
@@ -202,6 +246,37 @@ func handleGroup(group help.Group, args []string) {
case "monitor":
handleMonitorCommand(sub.Name, remaining)
return
case "schedule-type":
handleScheduleTypeCommand(sub.Name, remaining)
return
case "assign-schedule-type":
handleAssignScheduleType(remaining)
return
}
if len(args) > 0 && args[0] == "update-discord-id" {
tokenFlag := ""
var filtered []string
for i := 1; i < len(args); i++ {
switch args[i] {
case "--token":
if i+1 < len(args) {
i++
tokenFlag = args[i]
}
default:
filtered = append(filtered, args[i])
}
}
if len(filtered) < 1 {
output.Error("usage: hf update-discord-id <username> [discord-id]")
}
discordID := ""
if len(filtered) >= 2 {
discordID = filtered[1]
}
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
return
}
output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name)
@@ -238,7 +313,8 @@ func handleUserCommand(subCmd string, args []string) {
}
commands.RunUserGet(filtered[0], tokenFlag)
case "create":
username, password, email, fullName := "", "", "", ""
username, password, email, fullName, discordUserID := "", "", "", "", ""
agentID, clawID := "", ""
for i := 0; i < len(filtered); i++ {
switch filtered[i] {
case "--user":
@@ -261,14 +337,59 @@ func handleUserCommand(subCmd string, args []string) {
i++
fullName = filtered[i]
}
case "--discord-user-id":
if i+1 < len(filtered) {
i++
discordUserID = filtered[i]
}
case "--agent-id":
if i+1 < len(filtered) {
i++
agentID = filtered[i]
}
case "--claw-identifier":
if i+1 < len(filtered) {
i++
clawID = filtered[i]
}
default:
output.Errorf("unknown flag: %s", filtered[i])
}
}
if username == "" {
output.Error("usage: hf user create --user <username>")
output.Error("usage: hf user create --user <username> [--agent-id <id> --claw-identifier <id>]")
}
commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag)
commands.RunUserCreate(username, password, email, fullName, discordUserID, agentID, clawID, accMgrTokenFlag)
case "bind-agent":
// `hf user bind-agent <username> [--agent-id <id>] [--claw-identifier <id>]`
// Backfills the agents row for an existing user. Pcexec env
// (AGENT_ID + `openclaw config get plugins.harbor-forge.identifier`)
// covers the flags when run from a pcexec session.
username, agentID, clawID := "", "", ""
for i := 0; i < len(filtered); i++ {
switch filtered[i] {
case "--agent-id":
if i+1 < len(filtered) {
i++
agentID = filtered[i]
}
case "--claw-identifier":
if i+1 < len(filtered) {
i++
clawID = filtered[i]
}
default:
if username == "" && !strings.HasPrefix(filtered[i], "--") {
username = filtered[i]
} else {
output.Errorf("unknown flag: %s", filtered[i])
}
}
}
if username == "" {
output.Error("usage: hf user bind-agent <username> [--agent-id <id>] [--claw-identifier <id>]")
}
commands.RunUserBindAgent(username, agentID, clawID, accMgrTokenFlag)
case "update":
if len(filtered) < 1 {
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")
@@ -289,6 +410,11 @@ func handleUserCommand(subCmd string, args []string) {
output.Error("usage: hf user delete <username>")
}
commands.RunUserDelete(filtered[0], tokenFlag)
case "reset-apikey":
if len(filtered) < 1 {
output.Error("usage: hf user reset-apikey <username>")
}
commands.RunUserResetAPIKey(filtered[0], tokenFlag, accMgrTokenFlag)
default:
output.Errorf("hf user %s is not implemented yet", subCmd)
}
@@ -608,6 +734,83 @@ func handleProjectCommand(subCmd string, args []string) {
}
}
func handleKnowledgeBaseCommand(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])
}
}
needArg := func(usage string) {
if len(filtered) < 1 {
output.Error(usage)
}
}
switch subCmd {
case "list":
commands.RunKnowledgeBaseList(filtered, tokenFlag)
case "get":
needArg("usage: hf knowledge-base get <kb-code>")
commands.RunKnowledgeBaseGet(filtered[0], tokenFlag)
case "create":
commands.RunKnowledgeBaseCreate(filtered, tokenFlag)
case "update":
needArg("usage: hf knowledge-base update <kb-code> [--title ...] [--desc ...]")
commands.RunKnowledgeBaseUpdate(filtered[0], filtered[1:], tokenFlag)
case "delete":
needArg("usage: hf knowledge-base delete <kb-code>")
commands.RunKnowledgeBaseDelete(filtered[0], tokenFlag)
case "tree":
needArg("usage: hf knowledge-base tree <kb-code>")
commands.RunKnowledgeBaseTree(filtered[0], tokenFlag)
case "link":
needArg("usage: hf knowledge-base link <kb-code> --project <project-code>")
commands.RunKnowledgeBaseLink(filtered[0], filtered[1:], tokenFlag)
case "unlink":
needArg("usage: hf knowledge-base unlink <kb-code> --project <project-code>")
commands.RunKnowledgeBaseUnlink(filtered[0], filtered[1:], tokenFlag)
case "topics":
needArg("usage: hf knowledge-base topics <kb-code>")
commands.RunKnowledgeBaseTopics(filtered[0], tokenFlag)
case "add-topic":
needArg("usage: hf knowledge-base add-topic <kb-code> --topic <name> [--desc ...]")
commands.RunKnowledgeBaseAddTopic(filtered[0], filtered[1:], tokenFlag)
case "update-topic":
needArg("usage: hf knowledge-base update-topic <topic-id> [--topic ...] [--desc ...]")
commands.RunKnowledgeBaseUpdateTopic(filtered[0], filtered[1:], tokenFlag)
case "delete-topic":
needArg("usage: hf knowledge-base delete-topic <topic-id>")
commands.RunKnowledgeBaseDeleteTopic(filtered[0], tokenFlag)
case "add-category":
commands.RunKnowledgeBaseAddCategory(filtered, tokenFlag)
case "update-category":
needArg("usage: hf knowledge-base update-category <category-id> [--name ...] [--parent ...] [--desc ...]")
commands.RunKnowledgeBaseUpdateCategory(filtered[0], filtered[1:], tokenFlag)
case "delete-category":
needArg("usage: hf knowledge-base delete-category <category-id>")
commands.RunKnowledgeBaseDeleteCategory(filtered[0], tokenFlag)
case "add-fact":
commands.RunKnowledgeBaseAddFact(filtered, tokenFlag)
case "update-fact":
needArg("usage: hf knowledge-base update-fact <fact-id> [--fact ...] [--category ...]")
commands.RunKnowledgeBaseUpdateFact(filtered[0], filtered[1:], tokenFlag)
case "delete-fact":
needArg("usage: hf knowledge-base delete-fact <fact-id>")
commands.RunKnowledgeBaseDeleteFact(filtered[0], tokenFlag)
default:
output.Errorf("hf knowledge-base %s is not implemented yet", subCmd)
}
}
func handleMeetingCommand(subCmd string, args []string) {
tokenFlag := ""
var filtered []string
@@ -1081,3 +1284,47 @@ func handleMonitorAPIKeyCommand(args []string, tokenFlag string) {
output.Errorf("unknown monitor api-key subcommand: %s", subCmd)
}
}
func handleScheduleTypeCommand(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.RunScheduleTypeList(tokenFlag)
case "create":
commands.RunScheduleTypeCreate(filtered, tokenFlag)
case "delete":
commands.RunScheduleTypeDelete(filtered, tokenFlag)
default:
output.Errorf("hf schedule-type %s is not implemented yet", subCmd)
}
}
func handleAssignScheduleType(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])
}
}
commands.RunAssignScheduleType(filtered, tokenFlag)
}

View File

@@ -14,14 +14,46 @@ import (
type Client struct {
BaseURL string
Token string
APIKey string
HTTPClient *http.Client
}
// New creates a Client with the given base URL and optional auth token.
//
// The token is sent as Authorization: Bearer when it looks like a JWT
// (eyJ-prefixed, three dot-separated segments). Anything else is treated as
// an API key and sent via X-API-Key. This lets call sites that historically
// passed an api-key as a "token" (e.g. the value returned by passmgr.GetToken
// in padded-cell mode) authenticate correctly without per-callsite churn.
func New(baseURL, token string) *Client {
c := &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
if isLikelyJWT(token) {
c.Token = token
} else if token != "" {
c.APIKey = token
}
return c
}
// isLikelyJWT returns true for tokens that look like a JSON Web Token:
// "eyJ"-prefixed (base64-encoded JSON header opening with `{"`) and exactly
// two dots separating header.payload.signature. API keys minted by the HF
// backend are hex (`/users/{id}/apikey` returns a 64-hex-char `key`); fabric
// keys use a `fak_` prefix. None of those match this shape.
func isLikelyJWT(token string) bool {
return strings.HasPrefix(token, "eyJ") && strings.Count(token, ".") == 2
}
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
func NewWithAPIKey(baseURL, apiKey string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
@@ -45,7 +77,9 @@ func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("cannot create request: %w", err)
}
if c.Token != "" {
if c.APIKey != "" {
req.Header.Set("X-API-Key", c.APIKey)
} else if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if body != nil {
@@ -93,7 +127,7 @@ func (c *Client) Delete(path string) ([]byte, error) {
// Health checks the API health endpoint and returns the response.
func (c *Client) Health() (map[string]interface{}, error) {
data, err := c.Get("/api/health/")
data, err := c.Get("/health")
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,104 @@
package client
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestIsLikelyJWT(t *testing.T) {
cases := []struct {
in string
want bool
why string
}{
// Real JWT minted by an HS256 signer (header `{"alg":"HS256","typ":"JWT"}`).
{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature", true, "valid JWT shape"},
// HF backend api-keys are 64-char hex from /users/{id}/apikey.
{"f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0", false, "hex api-key"},
// Fabric api-keys carry a fak_ prefix.
{"fak_30791357ca11ac2ff963999bf265f6a5f240593eb01c06fc", false, "fabric api-key"},
// eyJ prefix without three segments isn't a JWT.
{"eyJabc", false, "prefix only"},
{"eyJabc.def", false, "two segments"},
// Empty / nonsense.
{"", false, "empty"},
{"....", false, "dots only"},
}
for _, c := range cases {
if got := isLikelyJWT(c.in); got != c.want {
t.Errorf("isLikelyJWT(%q) = %v, want %v (%s)", c.in, got, c.want, c.why)
}
}
}
func TestNewAutoSelectsAuthHeader(t *testing.T) {
// Capture which auth header reaches the server for each token shape.
var lastReq *http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastReq = r
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
}))
defer srv.Close()
// api-key path: should go via X-API-Key, NOT Authorization.
apiKey := "f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0"
if _, err := New(srv.URL, apiKey).Get("/anything"); err != nil {
t.Fatalf("api-key call failed: %v", err)
}
if got := lastReq.Header.Get("X-API-Key"); got != apiKey {
t.Errorf("api-key not sent as X-API-Key (got %q)", got)
}
if got := lastReq.Header.Get("Authorization"); got != "" {
t.Errorf("api-key leaked into Authorization header (got %q)", got)
}
// JWT path: should go via Authorization: Bearer, NOT X-API-Key.
jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
if _, err := New(srv.URL, jwt).Get("/anything"); err != nil {
t.Fatalf("jwt call failed: %v", err)
}
if got := lastReq.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") || !strings.HasSuffix(got, jwt) {
t.Errorf("jwt not sent as Bearer (got %q)", got)
}
if got := lastReq.Header.Get("X-API-Key"); got != "" {
t.Errorf("jwt leaked into X-API-Key header (got %q)", got)
}
// Empty token: neither header set.
if _, err := New(srv.URL, "").Get("/anything"); err != nil {
t.Fatalf("empty-token call failed: %v", err)
}
if got := lastReq.Header.Get("Authorization"); got != "" {
t.Errorf("empty token set Authorization (got %q)", got)
}
if got := lastReq.Header.Get("X-API-Key"); got != "" {
t.Errorf("empty token set X-API-Key (got %q)", got)
}
}
func TestNewWithAPIKeyAlwaysUsesAPIKeyHeader(t *testing.T) {
// Even if someone passes a JWT-shaped string via NewWithAPIKey, it must
// still go via X-API-Key — the explicit constructor wins.
var lastReq *http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastReq = r
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
}))
defer srv.Close()
jwtShape := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
if _, err := NewWithAPIKey(srv.URL, jwtShape).Get("/anything"); err != nil {
t.Fatalf("call failed: %v", err)
}
if got := lastReq.Header.Get("X-API-Key"); got != jwtShape {
t.Errorf("NewWithAPIKey didn't use X-API-Key (got %q)", got)
}
if got := lastReq.Header.Get("Authorization"); got != "" {
t.Errorf("NewWithAPIKey set Authorization (got %q)", got)
}
}

140
internal/commands/agent.go Normal file
View File

@@ -0,0 +1,140 @@
// Package commands — agent runtime-status command (`hf agent status`).
//
// Wraps the plugin-facing `POST /calendar/agent/status` endpoint so agents
// driven from `pcexec` (which sets AGENT_ID/CLAW_IDENTIFIER env) can report
// their status from a workflow without writing curl in the middle of a
// `flow.md` procedure.
//
// The endpoint itself is unauthenticated at the HTTP layer — it identifies
// the agent purely from X-Agent-ID + X-Claw-Identifier headers — so this
// command does NOT call `ResolveToken`. Calling it from outside a pcexec
// session will fail because AGENT_ID/CLAW_IDENTIFIER won't be set.
package commands
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
)
// RunAgentStatus implements `hf agent status --set <status>`.
//
// Supported statuses (mirrors backend `AgentStatus` enum):
// idle | busy | on_call | exhausted | offline
//
// For `exhausted`, an optional `--reason <rate_limit|billing>` and
// `--recovery-at <ISO-8601>` can be provided.
func RunAgentStatus(args []string) {
target := ""
reason := ""
recoveryAt := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--set":
if i+1 >= len(args) {
output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline> [--reason <rate_limit|billing>] [--recovery-at <iso>]")
}
target = args[i+1]
i++
case "--reason":
if i+1 >= len(args) {
output.Error("--reason requires a value")
}
reason = args[i+1]
i++
case "--recovery-at":
if i+1 >= len(args) {
output.Error("--recovery-at requires an ISO-8601 timestamp")
}
recoveryAt = args[i+1]
i++
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if target == "" {
output.Error("--set <status> is required")
}
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
clawID := strings.TrimSpace(os.Getenv("CLAW_IDENTIFIER"))
if clawID == "" {
// Match the plugin convention: hostname fallback when CLAW_IDENTIFIER
// is unset. Most pcexec callers won't have it set in env.
if h, err := os.Hostname(); err == nil {
clawID = h
}
}
if agentID == "" {
output.Error("AGENT_ID env is not set — run via pcexec or export AGENT_ID first")
}
if clawID == "" {
output.Error("CLAW_IDENTIFIER env not set and hostname() failed — set CLAW_IDENTIFIER explicitly")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
body := map[string]interface{}{
"agent_id": agentID,
"claw_identifier": clawID,
"status": target,
}
if reason != "" {
body["exhaust_reason"] = reason
}
if recoveryAt != "" {
body["recovery_at"] = recoveryAt
}
payload, err := json.Marshal(body)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
url := strings.TrimRight(cfg.BaseURL, "/") + "/calendar/agent/status"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
output.Errorf("cannot build request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Claw-Identifier", clawID)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
output.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
output.Errorf("backend returned %d: %s", resp.StatusCode, buf.String())
}
if output.JSONMode {
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
fmt.Println(buf.String())
return
}
output.PrintKeyValue(
"agent_id", agentID,
"claw_identifier", clawID,
"status", target,
"ok", "true",
)
}

View File

@@ -0,0 +1,826 @@
package commands
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func writeTestConfig(t *testing.T, dir, baseURL string) {
config := map[string]string{
"base-url": baseURL,
}
data, err := json.Marshal(config)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
cfgPath := filepath.Join(dir, ".hf-config.json")
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
}
func buildCLI(t *testing.T, cliPath string) {
srcDir := filepath.Join("..", "..")
cmd := exec.Command("go", "build", "-o", cliPath, filepath.Join(srcDir, "cmd", "hf"))
if out, err := cmd.CombinedOutput(); err != nil {
t.Skipf("cannot build CLI: %v (out: %s)", err, string(out))
}
}
func runCLI(t *testing.T, dir, cliPath string, args ...string) (string, error) {
cmd := exec.Command(cliPath, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
out, err := cmd.CombinedOutput()
return string(out), err
}
// --- Tests: argument parsing / usage errors ---
func TestCalendarSchedule_MissingArgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// --token must come after subcommand: hf calendar schedule --token <tok>
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake")
if err == nil {
t.Fatalf("expected non-zero exit for missing args; got out=%s", out)
}
if !strings.Contains(out, "usage:") && !strings.Contains(out, "slot-type") {
t.Errorf("expected usage message with slot-type; got: %s", out)
}
}
func TestCalendarSchedule_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--bad-flag")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected 'unknown flag' in output; got: %s", out)
}
}
func TestCalendarShow_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"slots": []interface{}{}})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--bad-flag")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected 'unknown flag'; got: %s", out)
}
}
func TestCalendarEdit_MissingSlotID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing slot-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestCalendarCancel_MissingSlotID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing slot-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestCalendarPlanSchedule_MissingAt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30")
if err == nil {
t.Fatalf("expected error for missing --at")
}
if !strings.Contains(out, "--at") {
t.Errorf("expected --at error; got: %s", out)
}
}
func TestCalendarPlanEdit_NothingToEdit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1")
if err == nil {
t.Fatalf("expected error for nothing to edit")
}
if !strings.Contains(out, "nothing to edit") {
t.Errorf("expected 'nothing to edit' error; got: %s", out)
}
}
func TestCalendarPlanCancel_MissingPlanID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing plan-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
// --- Tests: JSON output ---
func TestCalendarSchedule_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/calendar/slots" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 42,
"slot_type": "Work",
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
if resp["slot_id"] != float64(42) {
t.Errorf("expected slot_id=42; got: %v", resp["slot_id"])
}
}
func TestCalendarShow_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/day" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{
map[string]interface{}{
"slot_id": 1,
"slot_type": "Work",
"scheduled_at": "09:00",
"estimated_duration": 30,
"priority": 50,
"status": "NotStarted",
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "show", "--token", "fake", "--date", "2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
slots, ok := resp["slots"].([]interface{})
if !ok || len(slots) == 0 {
t.Fatalf("expected slots array in JSON; got: %v", resp)
}
}
func TestCalendarDateList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/dates" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{"2026-04-01", "2026-04-02"},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
dates, ok := resp["dates"].([]interface{})
if !ok || len(dates) != 2 {
t.Errorf("expected 2 dates; got: %v", dates)
}
}
func TestCalendarPlanList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/plans" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{
map[string]interface{}{
"id": 1,
"slot_type": "Work",
"at_time": "09:00",
"estimated_duration": 30,
"is_active": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
plans, ok := resp["plans"].([]interface{})
if !ok || len(plans) == 0 {
t.Fatalf("expected plans array; got: %v", resp)
}
}
// --- Tests: human-readable output ---
func TestCalendarShow_HumanOutput_WithSlots(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{
map[string]interface{}{
"slot_id": 1,
"slot_type": "Work",
"scheduled_at": "09:00",
"estimated_duration": 30,
"priority": 50,
"status": "NotStarted",
},
map[string]interface{}{
"slot_id": "plan-1-2026-04-01",
"slot_type": "OnCall",
"scheduled_at": "14:00",
"estimated_duration": 60,
"priority": 40,
"status": "NotStarted",
"is_virtual": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--date", "2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
// Virtual slot should be marked as plan
if !strings.Contains(out, "plan") {
t.Errorf("expected human output to mark virtual slot as plan; got: %s", out)
}
}
func TestCalendarShow_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No slots") {
t.Errorf("expected 'No slots' for empty; got: %s", out)
}
}
func TestCalendarDateList_HumanOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{"2026-04-01", "2026-04-02"},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
for _, date := range []string{"2026-04-01", "2026-04-02"} {
if !strings.Contains(out, date) {
t.Errorf("expected date %s in output; got: %s", date, out)
}
}
}
func TestCalendarDateList_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No future dates") {
t.Errorf("expected 'No future dates'; got: %s", out)
}
}
func TestCalendarPlanList_HumanOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{
map[string]interface{}{
"id": 1,
"slot_type": "Work",
"at_time": "09:00",
"on_day": "Mon",
"estimated_duration": 30,
"is_active": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "09:00") || !strings.Contains(out, "Work") {
t.Errorf("expected plan data in output; got: %s", out)
}
}
func TestCalendarPlanList_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No schedule plans") {
t.Errorf("expected 'No schedule plans'; got: %s", out)
}
}
// --- Tests: error output ---
func TestCalendarShow_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
w.Write([]byte(`{"detail":"internal error"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
if err == nil {
t.Fatalf("expected error for 500 response")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarEdit_SlotNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"slot not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "999", "--slot-type", "Work")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarCancel_SlotNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"slot not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "999")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarPlanSchedule_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
w.Write([]byte(`{"detail":"db error"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00")
if err == nil {
t.Fatalf("expected error for 500")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarPlanCancel_PlanNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"plan not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "999")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
// --- Tests: workload warnings ---
func TestCalendarSchedule_WorkloadWarning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 1,
"warnings": []interface{}{
map[string]interface{}{
"type": "workload",
"message": "Daily minimum work workload (30 min) not met: current 0 min",
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "⚠") && !strings.Contains(out, "warning") {
t.Errorf("expected workload warning in output; got: %s", out)
}
}
// --- Tests: virtual slot routing ---
func TestCalendarEdit_VirtualSlot_RoutesCorrectly(t *testing.T) {
var editedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
editedPath = r.URL.Path
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 10,
"slot_type": "Work",
"scheduled_at": "10:00",
"estimated_duration": 30,
"status": "NotStarted",
"priority": 50,
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "plan-1-2026-04-01", "--scheduled-at", "10:00")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(editedPath, "/calendar/slots/virtual/") {
t.Errorf("expected virtual slot path /calendar/slots/virtual/...; got: %s", editedPath)
}
}
func TestCalendarCancel_VirtualSlot_RoutesCorrectly(t *testing.T) {
var cancelledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cancelledPath = r.URL.Path
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
_, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "plan-1-2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(cancelledPath, "/calendar/slots/virtual/") {
t.Errorf("expected virtual slot cancel path /calendar/slots/virtual/...; got: %s", cancelledPath)
}
}
// --- Tests: successful operations ---
func TestCalendarSchedule_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"slot_id": 5})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--job", "TASK-1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "slot scheduled") {
t.Errorf("expected 'slot scheduled' success message; got: %s", out)
}
}
func TestCalendarPlanSchedule_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00", "--on-day", "Mon")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan created") {
t.Errorf("expected 'plan created' success message; got: %s", out)
}
}
func TestCalendarPlanEdit_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "at_time": "10:00"})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1", "--at", "10:00")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan edited") {
t.Errorf("expected 'plan edited' success message; got: %s", out)
}
}
func TestCalendarPlanCancel_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan cancelled") {
t.Errorf("expected 'plan cancelled' success message; got: %s", out)
}
}
func TestCalendarCancel_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "slot cancelled") {
t.Errorf("expected 'slot cancelled' success message; got: %s", out)
}
}

View File

@@ -20,7 +20,7 @@ func RunConfigURL(url string) {
fmt.Printf("base-url set to %s\n", url)
}
// RunConfigAccMgrToken stores the account-manager token via pass_mgr.
// RunConfigAccMgrToken stores the account-manager token via secret-mgr.
func RunConfigAccMgrToken(token string) {
if token == "" {
output.Error("usage: hf config --acc-mgr-token <token>")

View File

@@ -13,7 +13,6 @@ import (
type essentialResponse struct {
ID int `json:"id"`
EssentialCode string `json:"essential_code"`
ProposalID int `json:"proposal_id"`
Type string `json:"type"`
Title string `json:"title"`
Description *string `json:"description"`
@@ -49,7 +48,7 @@ func RunEssentialList(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/proposes/" + proposalCode + "/essentials")
data, err := c.Get(proposalPath(c, proposalCode) + "/essentials")
if err != nil {
output.Errorf("failed to list essentials: %v", err)
}
@@ -146,7 +145,7 @@ func RunEssentialCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/proposes/"+proposalCode+"/essentials", bytes.NewReader(body))
data, err := c.Post(proposalPath(c, proposalCode)+"/essentials", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create essential: %v", err)
}
@@ -229,7 +228,7 @@ func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/proposes/"+proposalCode+"/essentials/"+essentialCode, bytes.NewReader(body))
_, err = c.Patch(proposalPath(c, proposalCode)+"/essentials/"+essentialCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update essential: %v", err)
}
@@ -266,7 +265,7 @@ func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag strin
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/proposes/" + proposalCode + "/essentials/" + essentialCode)
_, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode)
if err != nil {
output.Errorf("failed to delete essential: %v", err)
}

View File

@@ -0,0 +1,658 @@
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"
)
// kbResponse matches the backend KnowledgeBaseResponse schema.
type kbResponse struct {
ID int `json:"id"`
KnowledgeBaseCode string `json:"knowledge_base_code"`
Title string `json:"title"`
Description *string `json:"description"`
CreatedBy int `json:"created_by"`
CreatedAt string `json:"created_at"`
LastUpdatedAt string `json:"last_updated_at"`
}
type kbFactNode struct {
ID int `json:"id"`
Fact string `json:"fact"`
}
type kbCategoryNode struct {
ID int `json:"id"`
Name string `json:"name"`
Categories []kbCategoryNode `json:"categories"`
Facts []kbFactNode `json:"facts"`
}
type kbTopicNode struct {
ID int `json:"id"`
Topic string `json:"topic"`
Categories []kbCategoryNode `json:"categories"`
Facts []kbFactNode `json:"facts"`
}
type kbTree struct {
Title string `json:"title"`
KnowledgeBaseCode string `json:"knowledge_base_code"`
Topics []kbTopicNode `json:"topics"`
}
func kbClient(tokenFlag string) *client.Client {
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
return client.New(cfg.BaseURL, ResolveToken(tokenFlag))
}
func emitJSONOr(data []byte, fallback func()) {
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
}
fallback()
}
// ---------------------------------------------------------------------------
// Knowledge base CRUD
// ---------------------------------------------------------------------------
// RunKnowledgeBaseList implements `hf knowledge-base list [--project <project-code>]`.
func RunKnowledgeBaseList(args []string, tokenFlag string) {
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])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
c := kbClient(tokenFlag)
path := "/knowledge-bases"
if query != "" {
path += "?" + query
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list knowledge bases: %v", err)
}
emitJSONOr(data, func() {
var kbs []kbResponse
if err := json.Unmarshal(data, &kbs); err != nil {
output.Errorf("cannot parse knowledge base list: %v", err)
}
headers := []string{"CODE", "TITLE", "DESCRIPTION"}
var rows [][]string
for _, k := range kbs {
desc := ""
if k.Description != nil {
desc = *k.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
}
rows = append(rows, []string{k.KnowledgeBaseCode, k.Title, desc})
}
output.PrintTable(headers, rows)
})
}
// RunKnowledgeBaseGet implements `hf knowledge-base get <kb-code>`.
func RunKnowledgeBaseGet(code, tokenFlag string) {
c := kbClient(tokenFlag)
data, err := c.Get("/knowledge-bases/" + code)
if err != nil {
output.Errorf("failed to get knowledge base: %v", err)
}
emitJSONOr(data, func() {
var k kbResponse
if err := json.Unmarshal(data, &k); err != nil {
output.Errorf("cannot parse knowledge base: %v", err)
}
desc := ""
if k.Description != nil {
desc = *k.Description
}
output.PrintKeyValue(
"code", k.KnowledgeBaseCode,
"title", k.Title,
"description", desc,
"created", k.CreatedAt,
"updated", k.LastUpdatedAt,
)
})
}
// RunKnowledgeBaseCreate implements `hf knowledge-base create --title <t> [--desc <d>]`.
func RunKnowledgeBaseCreate(args []string, tokenFlag string) {
title, desc := "", ""
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 "--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 title == "" {
output.Error("usage: hf knowledge-base create --title <title> [--desc <description>]")
}
payload := map[string]interface{}{"title": title}
if desc != "" {
payload["description"] = desc
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
data, err := c.Post("/knowledge-bases", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create knowledge base: %v", err)
}
emitJSONOr(data, func() {
var k kbResponse
if err := json.Unmarshal(data, &k); err != nil {
fmt.Printf("knowledge base created: %s\n", title)
return
}
fmt.Printf("knowledge base created: %s (code: %s)\n", k.Title, k.KnowledgeBaseCode)
})
}
// RunKnowledgeBaseUpdate implements `hf knowledge-base update <kb-code> [--title ...] [--desc ...]`.
func RunKnowledgeBaseUpdate(code string, args []string, tokenFlag string) {
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 --title and/or --desc")
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
_, err := c.Patch("/knowledge-bases/"+code, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update knowledge base: %v", err)
}
fmt.Printf("knowledge base updated: %s\n", code)
}
// RunKnowledgeBaseDelete implements `hf knowledge-base delete <kb-code>`.
func RunKnowledgeBaseDelete(code, tokenFlag string) {
c := kbClient(tokenFlag)
_, err := c.Delete("/knowledge-bases/" + code)
if err != nil {
output.Errorf("failed to delete knowledge base: %v", err)
}
fmt.Printf("knowledge base deleted: %s\n", code)
}
// RunKnowledgeBaseTree implements `hf knowledge-base tree <kb-code>`.
func RunKnowledgeBaseTree(code, tokenFlag string) {
c := kbClient(tokenFlag)
data, err := c.Get("/knowledge-bases/" + code + "/tree")
if err != nil {
output.Errorf("failed to get knowledge base tree: %v", err)
}
emitJSONOr(data, func() {
var tree kbTree
if err := json.Unmarshal(data, &tree); err != nil {
output.Errorf("cannot parse tree: %v", err)
}
fmt.Printf("%s (%s)\n", tree.Title, tree.KnowledgeBaseCode)
for _, t := range tree.Topics {
fmt.Printf(" # %s [topic:%d]\n", t.Topic, t.ID)
for _, f := range t.Facts {
fmt.Printf(" - %s [fact:%d]\n", f.Fact, f.ID)
}
for _, cat := range t.Categories {
printKBCategory(cat, 2)
}
}
})
}
func printKBCategory(cat kbCategoryNode, depth int) {
indent := strings.Repeat(" ", depth)
fmt.Printf("%s> %s [category:%d]\n", indent, cat.Name, cat.ID)
for _, f := range cat.Facts {
fmt.Printf("%s - %s [fact:%d]\n", indent, f.Fact, f.ID)
}
for _, child := range cat.Categories {
printKBCategory(child, depth+1)
}
}
// ---------------------------------------------------------------------------
// Project links
// ---------------------------------------------------------------------------
// RunKnowledgeBaseLink implements `hf knowledge-base link <kb-code> --project <project-code>`.
func RunKnowledgeBaseLink(code string, args []string, tokenFlag string) {
project := ""
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]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if project == "" {
output.Error("usage: hf knowledge-base link <kb-code> --project <project-code>")
}
body, _ := json.Marshal(map[string]interface{}{"knowledge_base": code})
c := kbClient(tokenFlag)
_, err := c.Post("/projects/"+project+"/knowledge-bases", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to link knowledge base: %v", err)
}
fmt.Printf("knowledge base %s linked to project %s\n", code, project)
}
// RunKnowledgeBaseUnlink implements `hf knowledge-base unlink <kb-code> --project <project-code>`.
func RunKnowledgeBaseUnlink(code string, args []string, tokenFlag string) {
project := ""
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]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if project == "" {
output.Error("usage: hf knowledge-base unlink <kb-code> --project <project-code>")
}
c := kbClient(tokenFlag)
_, err := c.Delete("/projects/" + project + "/knowledge-bases/" + code)
if err != nil {
output.Errorf("failed to unlink knowledge base: %v", err)
}
fmt.Printf("knowledge base %s unlinked from project %s\n", code, project)
}
// ---------------------------------------------------------------------------
// Topics
// ---------------------------------------------------------------------------
// RunKnowledgeBaseTopics implements `hf knowledge-base topics <kb-code>`.
func RunKnowledgeBaseTopics(code, tokenFlag string) {
c := kbClient(tokenFlag)
data, err := c.Get("/knowledge-bases/" + code + "/topics")
if err != nil {
output.Errorf("failed to list topics: %v", err)
}
emitJSONOr(data, func() {
var topics []struct {
ID int `json:"id"`
Topic string `json:"topic"`
Description *string `json:"description"`
}
if err := json.Unmarshal(data, &topics); err != nil {
output.Errorf("cannot parse topics: %v", err)
}
headers := []string{"ID", "TOPIC", "DESCRIPTION"}
var rows [][]string
for _, t := range topics {
desc := ""
if t.Description != nil {
desc = *t.Description
}
rows = append(rows, []string{fmt.Sprintf("%d", t.ID), t.Topic, desc})
}
output.PrintTable(headers, rows)
})
}
// RunKnowledgeBaseAddTopic implements `hf knowledge-base add-topic <kb-code> --topic <name> [--desc ...]`.
func RunKnowledgeBaseAddTopic(code string, args []string, tokenFlag string) {
topic, desc := "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--topic":
if i+1 >= len(args) {
output.Error("--topic requires a value")
}
i++
topic = 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 topic == "" {
output.Error("usage: hf knowledge-base add-topic <kb-code> --topic <name> [--desc <description>]")
}
payload := map[string]interface{}{"topic": topic}
if desc != "" {
payload["description"] = desc
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
data, err := c.Post("/knowledge-bases/"+code+"/topics", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to add topic: %v", err)
}
emitJSONOr(data, func() { fmt.Printf("topic added: %s\n", topic) })
}
// RunKnowledgeBaseUpdateTopic implements `hf knowledge-base update-topic <topic-id> [--topic ...] [--desc ...]`.
func RunKnowledgeBaseUpdateTopic(topicID string, args []string, tokenFlag string) {
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--topic":
if i+1 >= len(args) {
output.Error("--topic requires a value")
}
i++
payload["topic"] = 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 --topic and/or --desc")
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
_, err := c.Patch("/knowledge-topics/"+topicID, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update topic: %v", err)
}
fmt.Printf("topic updated: %s\n", topicID)
}
// RunKnowledgeBaseDeleteTopic implements `hf knowledge-base delete-topic <topic-id>`.
func RunKnowledgeBaseDeleteTopic(topicID, tokenFlag string) {
c := kbClient(tokenFlag)
_, err := c.Delete("/knowledge-topics/" + topicID)
if err != nil {
output.Errorf("failed to delete topic: %v", err)
}
fmt.Printf("topic deleted: %s\n", topicID)
}
// ---------------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------------
// RunKnowledgeBaseAddCategory implements
// `hf knowledge-base add-category --topic <id> --name <n> [--parent <id>] [--desc ...]`.
func RunKnowledgeBaseAddCategory(args []string, tokenFlag string) {
name, desc := "", ""
var topicID, parentID *int
for i := 0; i < len(args); i++ {
switch args[i] {
case "--topic":
if i+1 >= len(args) {
output.Error("--topic requires a value")
}
i++
topicID = parseIntFlag("--topic", args[i])
case "--parent":
if i+1 >= len(args) {
output.Error("--parent requires a value")
}
i++
parentID = parseIntFlag("--parent", 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]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if topicID == nil || name == "" {
output.Error("usage: hf knowledge-base add-category --topic <topic-id> --name <name> [--parent <category-id>] [--desc <description>]")
}
payload := map[string]interface{}{"topic_id": *topicID, "name": name}
if parentID != nil {
payload["parent"] = *parentID
}
if desc != "" {
payload["description"] = desc
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
data, err := c.Post("/knowledge-categories", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to add category: %v", err)
}
emitJSONOr(data, func() { fmt.Printf("category added: %s\n", name) })
}
// RunKnowledgeBaseUpdateCategory implements
// `hf knowledge-base update-category <category-id> [--name ...] [--parent ...] [--desc ...]`.
func RunKnowledgeBaseUpdateCategory(categoryID string, args []string, tokenFlag string) {
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 "--parent":
if i+1 >= len(args) {
output.Error("--parent requires a value")
}
i++
payload["parent"] = *parseIntFlag("--parent", 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 --name, --parent and/or --desc")
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
_, err := c.Patch("/knowledge-categories/"+categoryID, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update category: %v", err)
}
fmt.Printf("category updated: %s\n", categoryID)
}
// RunKnowledgeBaseDeleteCategory implements `hf knowledge-base delete-category <category-id>`.
func RunKnowledgeBaseDeleteCategory(categoryID, tokenFlag string) {
c := kbClient(tokenFlag)
_, err := c.Delete("/knowledge-categories/" + categoryID)
if err != nil {
output.Errorf("failed to delete category: %v", err)
}
fmt.Printf("category deleted: %s\n", categoryID)
}
// ---------------------------------------------------------------------------
// Facts
// ---------------------------------------------------------------------------
// RunKnowledgeBaseAddFact implements
// `hf knowledge-base add-fact --topic <id> [--category <id>] --fact <text>`.
func RunKnowledgeBaseAddFact(args []string, tokenFlag string) {
factText := ""
var topicID, categoryID *int
for i := 0; i < len(args); i++ {
switch args[i] {
case "--topic":
if i+1 >= len(args) {
output.Error("--topic requires a value")
}
i++
topicID = parseIntFlag("--topic", args[i])
case "--category":
if i+1 >= len(args) {
output.Error("--category requires a value")
}
i++
categoryID = parseIntFlag("--category", args[i])
case "--fact":
if i+1 >= len(args) {
output.Error("--fact requires a value")
}
i++
factText = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if topicID == nil || factText == "" {
output.Error("usage: hf knowledge-base add-fact --topic <topic-id> [--category <category-id>] --fact <text>")
}
payload := map[string]interface{}{"topic_id": *topicID, "fact": factText}
if categoryID != nil {
payload["category_id"] = *categoryID
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
data, err := c.Post("/knowledge-facts", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to add fact: %v", err)
}
emitJSONOr(data, func() { fmt.Println("fact added") })
}
// RunKnowledgeBaseUpdateFact implements
// `hf knowledge-base update-fact <fact-id> [--fact ...] [--category ...]`.
func RunKnowledgeBaseUpdateFact(factID string, args []string, tokenFlag string) {
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--fact":
if i+1 >= len(args) {
output.Error("--fact requires a value")
}
i++
payload["fact"] = args[i]
case "--category":
if i+1 >= len(args) {
output.Error("--category requires a value")
}
i++
payload["category_id"] = *parseIntFlag("--category", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide --fact and/or --category")
}
body, _ := json.Marshal(payload)
c := kbClient(tokenFlag)
_, err := c.Patch("/knowledge-facts/"+factID, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update fact: %v", err)
}
fmt.Printf("fact updated: %s\n", factID)
}
// RunKnowledgeBaseDeleteFact implements `hf knowledge-base delete-fact <fact-id>`.
func RunKnowledgeBaseDeleteFact(factID, tokenFlag string) {
c := kbClient(tokenFlag)
_, err := c.Delete("/knowledge-facts/" + factID)
if err != nil {
output.Errorf("failed to delete fact: %v", err)
}
fmt.Printf("fact deleted: %s\n", factID)
}
func parseIntFlag(flag, value string) *int {
var n int
if _, err := fmt.Sscanf(value, "%d", &n); err != nil {
output.Errorf("%s requires an integer value, got %q", flag, value)
}
return &n
}

View File

@@ -36,7 +36,7 @@ func RunMeetingList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")

View File

@@ -44,7 +44,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")

View File

@@ -12,8 +12,10 @@ import (
// monitorOverviewResponse matches the backend monitor overview schema.
type monitorOverviewResponse struct {
TotalServers int `json:"total_servers"`
OnlineServers int `json:"online_servers"`
Tasks interface{} `json:"tasks"`
Providers interface{} `json:"providers"`
Servers []monitorServerResponse `json:"servers"`
GeneratedAt string `json:"generated_at"`
}
// monitorServerResponse matches the backend monitor server schema.
@@ -28,8 +30,32 @@ type monitorServerResponse struct {
// monitorAPIKeyResponse matches the backend monitor API key schema.
type monitorAPIKeyResponse struct {
Identifier string `json:"identifier"`
APIKey string `json:"api_key"`
ServerID int `json:"server_id"`
APIKey string `json:"api_key"`
Message string `json:"message"`
}
func monitorServerList(c *client.Client) []monitorServerResponse {
data, err := c.Get("/monitor/admin/servers")
if err != nil {
output.Errorf("failed to list monitor servers: %v", err)
}
var servers []monitorServerResponse
if err := json.Unmarshal(data, &servers); err != nil {
output.Errorf("cannot parse server list: %v", err)
}
return servers
}
func resolveMonitorServerID(c *client.Client, identifier string) int {
servers := monitorServerList(c)
for _, s := range servers {
if s.Identifier == identifier {
return s.ID
}
}
output.Errorf("monitor server not found: %s", identifier)
return 0
}
// RunMonitorOverview implements `hf monitor overview`.
@@ -40,7 +66,7 @@ func RunMonitorOverview(tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/monitor/overview")
data, err := c.Get("/monitor/public/overview")
if err != nil {
output.Errorf("failed to get monitor overview: %v", err)
}
@@ -59,9 +85,16 @@ func RunMonitorOverview(tokenFlag string) {
output.Errorf("cannot parse monitor overview: %v", err)
}
online := 0
for _, s := range o.Servers {
if s.Status == "online" {
online++
}
}
output.PrintKeyValue(
"total-servers", fmt.Sprintf("%d", o.TotalServers),
"online-servers", fmt.Sprintf("%d", o.OnlineServers),
"total-servers", fmt.Sprintf("%d", len(o.Servers)),
"online-servers", fmt.Sprintf("%d", online),
"generated-at", o.GeneratedAt,
)
}
@@ -73,7 +106,7 @@ func RunMonitorServerList(tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/monitor/servers")
data, err := c.Get("/monitor/admin/servers")
if err != nil {
output.Errorf("failed to list monitor servers: %v", err)
}
@@ -116,39 +149,37 @@ func RunMonitorServerGet(identifier, tokenFlag string) {
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)
servers := monitorServerList(c)
var found *monitorServerResponse
for i := range servers {
if servers[i].Identifier == identifier {
found = &servers[i]
break
}
}
if found == nil {
output.Errorf("failed to get server: not found: %s", identifier)
}
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)
output.PrintJSON(found)
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
if found.DisplayName != nil {
name = *found.DisplayName
}
lastSeen := ""
if s.LastSeen != nil {
lastSeen = *s.LastSeen
if found.LastSeen != nil {
lastSeen = *found.LastSeen
}
output.PrintKeyValue(
"identifier", s.Identifier,
"identifier", found.Identifier,
"name", name,
"status", s.Status,
"status", found.Status,
"last-seen", lastSeen,
"created", s.CreatedAt,
"created", found.CreatedAt,
)
}
@@ -197,7 +228,7 @@ func RunMonitorServerCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/monitor/servers", bytes.NewReader(body))
data, err := c.Post("/monitor/admin/servers", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create server: %v", err)
}
@@ -223,7 +254,8 @@ func RunMonitorServerDelete(identifier, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/monitor/servers/" + identifier)
serverID := resolveMonitorServerID(c, identifier)
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d", serverID))
if err != nil {
output.Errorf("failed to delete server: %v", err)
}
@@ -238,7 +270,8 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/monitor/servers/"+identifier+"/api-key", nil)
serverID := resolveMonitorServerID(c, identifier)
data, err := c.Post(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID), nil)
if err != nil {
output.Errorf("failed to generate API key: %v", err)
}
@@ -258,8 +291,9 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
return
}
output.PrintKeyValue(
"identifier", k.Identifier,
"server-id", fmt.Sprintf("%d", k.ServerID),
"api-key", k.APIKey,
"message", k.Message,
)
}
@@ -271,7 +305,8 @@ func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/monitor/servers/" + identifier + "/api-key")
serverID := resolveMonitorServerID(c, identifier)
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID))
if err != nil {
output.Errorf("failed to revoke API key: %v", err)
}

View File

@@ -0,0 +1,531 @@
package commands
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func runCLIProposal(t *testing.T, dir, cliPath string, args ...string) (string, error) {
cmd := exec.Command(cliPath, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
out, err := cmd.CombinedOutput()
return string(out), err
}
// --- Essential subcommand tests ---
func TestEssentialList_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "list", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
func TestEssentialCreate_MissingRequired(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Missing --proposal, --title, --type
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001")
if err == nil {
t.Fatalf("expected error for missing required args")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestEssentialCreate_InvalidType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Test", "--type", "invalid-type")
if err == nil {
t.Fatalf("expected error for invalid type")
}
if !strings.Contains(out, "invalid essential type") {
t.Errorf("expected invalid type error; got: %s", out)
}
}
func TestEssentialCreate_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Test", "--type", "feature", "--unknown")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected unknown flag error; got: %s", out)
}
}
func TestEssentialUpdate_NothingToUpdate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001")
if err == nil {
t.Fatalf("expected error for nothing to update")
}
if !strings.Contains(out, "nothing to update") {
t.Errorf("expected 'nothing to update' error; got: %s", out)
}
}
func TestEssentialUpdate_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
func TestEssentialDelete_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", "ESS-001")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
// --- Essential list/create JSON output ---
func TestEssentialList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"essential_code": "ESS-001",
"proposal_id": 1,
"type": "feature",
"title": "Add login",
"created_at": "2026-03-01",
},
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "essential", "list", "--token", "fake", "--proposal", "PRJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
}
func TestEssentialCreate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"essential_code": "ESS-001",
"title": "Add login",
"type": "feature",
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Add login", "--type", "feature")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential created") {
t.Errorf("expected 'essential created' success message; got: %s", out)
}
}
func TestEssentialUpdate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "PATCH" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
w.WriteHeader(200)
w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001", "--title", "Updated title")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential updated") {
t.Errorf("expected 'essential updated' success message; got: %s", out)
}
}
func TestEssentialDelete_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "DELETE" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
w.WriteHeader(200)
w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential deleted") {
t.Errorf("expected 'essential deleted' success message; got: %s", out)
}
}
// --- Proposal Accept tests ---
func TestProposalAccept_MissingMilestone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", "PRJ-001")
if err == nil {
t.Fatalf("expected error for missing --milestone")
}
if !strings.Contains(out, "--milestone") {
t.Errorf("expected --milestone error; got: %s", out)
}
}
func TestProposalAccept_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/accept":
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["milestone_code"] != "MS-001" {
t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"])
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": "PRJ-001",
"status": "Accepted",
"tasks": []interface{}{},
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake",
"PRJ-001", "--milestone", "MS-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "proposal accepted") {
t.Errorf("expected proposal accepted success message; got: %s", out)
}
}
func TestProposalAccept_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": "PRJ-001",
"status": "Accepted",
"tasks": []interface{}{
map[string]interface{}{"code": "TASK-1", "type": "story/feature"},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "accept", "--token", "fake",
"PRJ-001", "--milestone", "MS-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
if resp["status"] != "Accepted" {
t.Errorf("expected status=Accepted; got: %v", resp["status"])
}
}
// --- Story restricted tests ---
func TestTaskCreate_StoryRestricted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Try to create a story/feature directly
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
"--type", "story/feature", "--title", "My story", "--project", "PRJ-001")
if err == nil {
t.Fatalf("expected error for restricted story creation")
}
if !strings.Contains(out, "restricted") && !strings.Contains(out, "proposal accept") {
t.Errorf("expected restricted error mentioning proposal accept; got: %s", out)
}
}
func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("server should not be called for restricted story type")
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Just "story" without slash should also be blocked
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
"--type", "story", "--title", "My story", "--project", "PRJ-001")
if err == nil {
t.Fatalf("expected error for restricted story type")
}
if !strings.Contains(out, "restricted") {
t.Errorf("expected restricted error; got: %s", out)
}
}
// --- Proposal list tests ---
func TestProposalList_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"code": "PRJ-001",
"title": "My Proposal",
"status": "Open",
"project_code": "PROJ-001",
"created_by": "alice",
"created_at": "2026-03-01",
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake", "--project", "PROJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "PRJ-001") {
t.Errorf("expected proposal code in output; got: %s", out)
}
}
func TestProposalList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"code": "PRJ-001",
"title": "My Proposal",
"status": "Open",
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake", "--project", "PROJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
@@ -23,11 +24,44 @@ type proposeResponse struct {
CreatedAt string `json:"created_at"`
}
type projectLookup struct {
ID int `json:"id"`
ProjectCode string `json:"project_code"`
}
func resolveProposalProject(c *client.Client, proposalCode string) string {
data, err := c.Get("/projects")
if err != nil {
return ""
}
var projects []projectLookup
if err := json.Unmarshal(data, &projects); err != nil {
return ""
}
for _, p := range projects {
if p.ProjectCode == "" {
continue
}
if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil {
return p.ProjectCode
}
}
return ""
}
func proposalPath(c *client.Client, proposalCode string) string {
if project := resolveProposalProject(c, proposalCode); project != "" {
return "/projects/" + project + "/proposals/" + proposalCode
}
return "/proposes/" + proposalCode
}
// RunProposeList implements `hf propose list --project <project-code>`.
func RunProposeList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
query := ""
project := ""
query := url.Values{}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
@@ -35,32 +69,39 @@ func RunProposeList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
project = args[i]
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query = appendQuery(query, "status", args[i])
query.Set("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])
query.Set("order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
legacyPath := false
if project == "" {
legacyPath = true
}
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
path := "/projects/" + project + "/proposals"
if legacyPath {
path = "/proposes"
}
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
data, err := c.Get(path)
if err != nil {
@@ -105,7 +146,7 @@ func RunProposeGet(proposeCode, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/proposes/" + proposeCode)
data, err := c.Get(proposalPath(c, proposeCode))
if err != nil {
output.Errorf("failed to get proposal: %v", err)
}
@@ -178,9 +219,8 @@ func RunProposeCreate(args []string, tokenFlag string) {
}
payload := map[string]interface{}{
"project_code": project,
"title": title,
"description": desc,
"title": title,
"description": desc,
}
body, err := json.Marshal(payload)
@@ -193,7 +233,7 @@ func RunProposeCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/proposes", bytes.NewReader(body))
data, err := c.Post("/projects/"+project+"/proposals", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create proposal: %v", err)
}
@@ -253,7 +293,7 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/proposes/"+proposeCode, bytes.NewReader(body))
_, err = c.Patch(proposalPath(c, proposeCode), bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update proposal: %v", err)
}
@@ -311,7 +351,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body))
data, err := c.Post(proposalPath(c, proposeCode)+"/accept", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to accept proposal: %v", err)
}
@@ -332,7 +372,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 {
fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks))
for _, gt := range resp.GeneratedTasks {
code := ""
code := "(no task_code)"
if gt.TaskCode != nil {
code = *gt.TaskCode
}
@@ -380,7 +420,7 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/proposes/"+proposeCode+"/reject", body)
_, err = c.Post(proposalPath(c, proposeCode)+"/reject", body)
if err != nil {
output.Errorf("failed to reject proposal: %v", err)
}
@@ -397,7 +437,7 @@ func RunProposeReopen(proposeCode, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/proposes/"+proposeCode+"/reopen", nil)
_, err = c.Post(proposalPath(c, proposeCode)+"/reopen", nil)
if err != nil {
output.Errorf("failed to reopen proposal: %v", err)
}

View File

@@ -0,0 +1,165 @@
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 scheduleTypeResponse struct {
ID int `json:"id"`
Name string `json:"name"`
WorkFrom int `json:"work_from"`
WorkTo int `json:"work_to"`
EntertainmentFrom int `json:"entertainment_from"`
EntertainmentTo int `json:"entertainment_to"`
}
// RunScheduleTypeList implements `hf schedule-type list`.
func RunScheduleTypeList(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("/schedule-types/")
if err != nil {
output.Errorf("failed to list schedule types: %v", err)
}
var types []scheduleTypeResponse
if err := json.Unmarshal(data, &types); err != nil {
output.Errorf("invalid response: %v", err)
}
if output.JSONMode {
output.PrintJSON(types)
return
}
if len(types) == 0 {
fmt.Println("No schedule types defined.")
return
}
fmt.Printf("%-4s %-20s %-12s %-12s\n", "ID", "Name", "Work", "Entertainment")
fmt.Printf("%-4s %-20s %-12s %-12s\n", "----", "--------------------", "------------", "------------")
for _, t := range types {
fmt.Printf("%-4d %-20s %02d:00-%02d:00 %02d:00-%02d:00\n",
t.ID, t.Name, t.WorkFrom, t.WorkTo, t.EntertainmentFrom, t.EntertainmentTo)
}
}
// RunScheduleTypeCreate implements `hf schedule-type create <name> --work <from>-<to> --entertainment <from>-<to>`.
func RunScheduleTypeCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf schedule-type create <name> --work <from>-<to> --entertainment <from>-<to>")
}
name := args[0]
workFrom, workTo, entFrom, entTo := -1, -1, -1, -1
for i := 1; i < len(args); i++ {
switch args[i] {
case "--work":
if i+1 < len(args) {
i++
fmt.Sscanf(args[i], "%d-%d", &workFrom, &workTo)
}
case "--entertainment":
if i+1 < len(args) {
i++
fmt.Sscanf(args[i], "%d-%d", &entFrom, &entTo)
}
}
}
if workFrom < 0 || workTo < 0 || entFrom < 0 || entTo < 0 {
output.Error("usage: hf schedule-type create <name> --work <from>-<to> --entertainment <from>-<to>\n e.g.: hf schedule-type create standard --work 8-18 --entertainment 19-23")
}
body := map[string]any{
"name": name,
"work_from": workFrom,
"work_to": workTo,
"entertainment_from": entFrom,
"entertainment_to": entTo,
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
jsonBody, _ := json.Marshal(body)
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/schedule-types/", bytes.NewReader(jsonBody))
if err != nil {
output.Errorf("failed to create schedule type: %v", err)
}
var resp scheduleTypeResponse
json.Unmarshal(data, &resp)
fmt.Printf("Created schedule type: %s (id=%d, work=%02d:00-%02d:00, entertainment=%02d:00-%02d:00)\n",
resp.Name, resp.ID, resp.WorkFrom, resp.WorkTo, resp.EntertainmentFrom, resp.EntertainmentTo)
}
// RunScheduleTypeDelete implements `hf schedule-type delete <id>`.
func RunScheduleTypeDelete(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf schedule-type delete <id>")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/schedule-types/" + args[0])
if err != nil {
output.Errorf("failed to delete schedule type: %v", err)
}
fmt.Printf("Deleted schedule type %s\n", args[0])
}
// RunAssignScheduleType implements `hf assign-schedule-type <agent-id> <schedule-type-name>`.
func RunAssignScheduleType(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 2 {
output.Error("usage: hf assign-schedule-type <agent-id> <schedule-type-name>")
}
agentID := args[0]
scheduleName := args[1]
body := map[string]string{
"schedule_type_name": scheduleName,
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
jsonBody, _ := json.Marshal(body)
c := client.New(cfg.BaseURL, token)
data, err := c.Put("/schedule-types/agent/"+agentID+"/assign", bytes.NewReader(jsonBody))
if err != nil {
output.Errorf("failed to assign schedule type: %v", err)
}
var resp map[string]any
json.Unmarshal(data, &resp)
fmt.Printf("Assigned schedule type '%s' to agent '%s'\n", scheduleName, agentID)
}

View File

@@ -38,13 +38,13 @@ func RunTaskList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--milestone":
if i+1 >= len(args) {
output.Error("--milestone requires a value")
}
i++
query = appendQuery(query, "milestone", args[i])
query = appendQuery(query, "milestone_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
@@ -426,7 +426,7 @@ func RunTaskSearch(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")

View File

@@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
@@ -23,6 +25,7 @@ type userResponse struct {
IsAdmin bool `json:"is_admin"`
RoleID *int `json:"role_id"`
RoleName *string `json:"role_name"`
DiscordUserID *string `json:"discord_user_id"`
CreatedAt string `json:"created_at"`
}
@@ -137,10 +140,100 @@ type userCreatePayload struct {
Email string `json:"email"`
FullName *string `json:"full_name,omitempty"`
Password *string `json:"password,omitempty"`
DiscordUserID *string `json:"discord_user_id,omitempty"`
// Agent binding — both fields go together or both stay nil.
// Backend rejects (400) if only one is set.
AgentID *string `json:"agent_id,omitempty"`
ClawIdentifier *string `json:"claw_identifier,omitempty"`
}
// userBindAgentPayload is the JSON body for PATCH /users/{id}/bind-agent.
type userBindAgentPayload struct {
AgentID string `json:"agent_id"`
ClawIdentifier string `json:"claw_identifier"`
}
// resolveAgentBinding picks the final (agent_id, claw_identifier) pair
// to send to the backend.
//
// Explicit flags win. If either is empty, fall back to the pcexec env
// (AGENT_ID) and to `openclaw config get plugins.harbor-forge.identifier`
// for the claw — same convention as the openclaw plugin's heartbeat.
//
// Honours the backend's "both or neither" invariant: if only one side
// can be resolved, returns (nil, nil) — caller's intent of creating a
// non-agent user is preserved instead of producing a 400. When the
// caller actually NEEDS the binding (e.g. `hf user bind-agent` is the
// whole point of the command), set `requireBoth=true` to fail loudly
// instead.
func resolveAgentBinding(explicitAgentID, explicitClawID string, requireBoth bool) (*string, *string) {
agentID := strings.TrimSpace(explicitAgentID)
clawID := strings.TrimSpace(explicitClawID)
if agentID == "" {
agentID = strings.TrimSpace(os.Getenv("AGENT_ID"))
}
if clawID == "" {
if v, err := exec.Command("openclaw", "config", "get", "plugins.harbor-forge.identifier").Output(); err == nil {
clawID = strings.TrimSpace(string(v))
}
}
if agentID == "" && clawID == "" {
if requireBoth {
output.Error("--agent-id and --claw-identifier required (AGENT_ID env and `openclaw config get plugins.harbor-forge.identifier` both empty)")
}
return nil, nil
}
if agentID == "" || clawID == "" {
if requireBoth {
output.Errorf(
"could not resolve agent binding pair: agent_id=%q claw_identifier=%q (need both)",
agentID, clawID,
)
}
return nil, nil
}
return &agentID, &clawID
}
func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, error) {
if strings.TrimSpace(explicit) != "" {
return strings.TrimSpace(explicit), true, nil
}
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
agentVerify := strings.TrimSpace(os.Getenv("AGENT_VERIFY"))
if agentID == "" || agentVerify == "" {
if requireEnv {
return "", false, fmt.Errorf("discord id not provided and AGENT_ID/AGENT_VERIFY are missing")
}
return "", false, nil
}
cmd := exec.Command("ego-mgr", "get", "discord-id")
out, err := cmd.Output()
if err != nil {
if requireEnv {
return "", false, fmt.Errorf("failed to resolve discord id from ego-mgr: %w", err)
}
return "", false, nil
}
value := strings.TrimSpace(string(out))
if value == "" {
if requireEnv {
return "", false, fmt.Errorf("ego-mgr returned empty discord id")
}
return "", false, nil
}
return value, true, nil
}
// RunUserCreate implements `hf user create`.
func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) {
//
// Agent binding: if `agentIDFlag` + `clawIDFlag` are both set (or both
// resolvable from env), the backend creates the matching agents row in
// the same transaction. Partial pair → treated as "neither" so callers
// who didn't want a binding still get a normal user.
func RunUserCreate(username, password, email, fullName, discordUserID, agentIDFlag, clawIDFlag, accMgrTokenFlag string) {
// Resolve account-manager token
var accMgrToken string
if mode.IsPaddedCell() {
@@ -181,10 +274,21 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
Email: email,
Password: &password,
}
if resolvedDiscordID, ok, err := maybeResolveDiscordUserID(discordUserID, false); err != nil {
output.Errorf("failed to resolve discord user id: %v", err)
} else if ok {
payload.DiscordUserID = &resolvedDiscordID
}
if fullName != "" {
payload.FullName = &fullName
}
// Agent binding — resolve from flags + pcexec env fallback.
if agentID, clawID := resolveAgentBinding(agentIDFlag, clawIDFlag, false); agentID != nil && clawID != nil {
payload.AgentID = agentID
payload.ClawIdentifier = clawID
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
@@ -194,7 +298,7 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, accMgrToken)
c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken)
data, err := c.Post("/users", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create user: %v", err)
@@ -216,6 +320,28 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
fmt.Printf("user created: %s\n", u.Username)
}
// RunUserUpdateDiscordID updates a user's discord_user_id field.
func RunUserUpdateDiscordID(username, discordUserID, tokenFlag string) {
token := ResolveToken(tokenFlag)
resolvedDiscordID, _, err := maybeResolveDiscordUserID(discordUserID, true)
if err != nil {
output.Errorf("failed to resolve discord user id: %v", err)
}
body, err := json.Marshal(map[string]interface{}{"discord_user_id": resolvedDiscordID})
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)
if _, err := c.Patch("/users/"+username, bytes.NewReader(body)); err != nil {
output.Errorf("failed to update discord id: %v", err)
}
fmt.Printf("discord id updated: %s\n", username)
}
// RunUserUpdate implements `hf user update <username>`.
func RunUserUpdate(username string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
@@ -329,3 +455,139 @@ func RunUserDelete(username, tokenFlag string) {
}
fmt.Printf("user deleted: %s\n", username)
}
// resetAPIKeyResponse matches the backend reset-apikey response.
type resetAPIKeyResponse struct {
UserID int `json:"user_id"`
Username string `json:"username"`
APIKey string `json:"api_key"`
Message string `json:"message"`
}
// RunUserResetAPIKey implements `hf user reset-apikey <username>`.
func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) {
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
// Try acc-mgr-token first (allows provisioning without existing user token)
var c *client.Client
if accMgrTokenFlag != "" {
c = client.NewWithAPIKey(cfg.BaseURL, accMgrTokenFlag)
} else if mode.IsPaddedCell() {
if tok, err := passmgr.GetAccountManagerToken(); err == nil && tok != "" {
c = client.NewWithAPIKey(cfg.BaseURL, tok)
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
data, err := c.Post("/users/"+username+"/reset-apikey", nil)
if err != nil {
output.Errorf("failed to reset 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 r resetAPIKeyResponse
if err := json.Unmarshal(data, &r); err != nil {
fmt.Printf("API key reset for: %s\n", username)
return
}
output.PrintKeyValue(
"username", r.Username,
"api-key", r.APIKey,
"message", r.Message,
)
}
// RunUserBindAgent implements `hf user bind-agent <username>`.
//
// Backfills the agents row for an existing user that was created via
// the old `hf user create` (which did not accept --agent-id /
// --claw-identifier). Hits PATCH /users/{username}/bind-agent.
//
// Uses an account-manager token like `hf user create` does — same
// permission surface (`account.create`).
func RunUserBindAgent(username, agentIDFlag, clawIDFlag, accMgrTokenFlag string) {
if username == "" {
output.Error("usage: hf user bind-agent <username> [--agent-id <id>] [--claw-identifier <id>]")
}
// Resolve account-manager token (same as RunUserCreate).
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 <token> required or execute with pcexec")
}
accMgrToken = tok
} else {
if accMgrTokenFlag == "" {
output.Error("--acc-mgr-token <token> required or execute with pcexec")
}
accMgrToken = accMgrTokenFlag
}
// Resolve agent binding — REQUIRED for this command (errors out on partial).
agentID, clawID := resolveAgentBinding(agentIDFlag, clawIDFlag, true)
if agentID == nil || clawID == nil {
output.Error("--agent-id and --claw-identifier could not be resolved (bug — requireBoth should have errored already)")
}
payload := userBindAgentPayload{
AgentID: *agentID,
ClawIdentifier: *clawID,
}
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.NewWithAPIKey(cfg.BaseURL, accMgrToken)
data, err := c.Patch("/users/"+username+"/bind-agent", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to bind agent: %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 {
fmt.Printf("agent bound to %s (agent_id=%s, claw=%s)\n", username, *agentID, *clawID)
return
}
output.PrintKeyValue(
"username", u.Username,
"user_id", fmt.Sprint(u.ID),
"agent_id", *agentID,
"claw_identifier", *clawID,
"message", "agent binding written",
)
}

View File

@@ -95,9 +95,9 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
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",
Summary: "Store the account-manager token via secret-mgr",
Usage: []string{"hf config --acc-mgr-token <token>"},
Notes: []string{"Only available in padded-cell mode with pass_mgr installed."},
Notes: []string{"Only available in padded-cell mode with secret-mgr installed."},
},
"user/create": {
Summary: "Create a user account",
@@ -105,7 +105,7 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
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.",
"In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to secret-mgr.",
},
},
"user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()},
@@ -114,6 +114,7 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
"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()},
"user/reset-apikey": {Summary: "Reset a user's API key", Usage: []string{"hf user reset-apikey <username>"}, Flags: authFlagHelp(), Notes: []string{"The new API key is shown once and cannot be retrieved again."}},
"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()},

View File

@@ -2,6 +2,7 @@ package help
import (
"encoding/json"
"os"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
@@ -28,6 +29,13 @@ func CommandSurface() []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: "agent",
Description: "Runtime status reporting for the calling agent (uses AGENT_ID/CLAW_IDENTIFIER env)",
SubCommands: []Command{
{Name: "status", Description: "Report runtime status: hf agent status --set <idle|busy|on_call|exhausted|offline>", Permitted: true},
},
},
{
Name: "user",
Description: "Manage users",
@@ -39,6 +47,8 @@ func CommandSurface() []Group {
{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: "reset-apikey", Description: "Reset a user's API key", Permitted: true},
{Name: "bind-agent", Description: "Backfill the agents row for an existing user (acc-mgr token; use --agent-id / --claw-identifier or AGENT_ID env)", Permitted: true},
},
},
{
@@ -66,7 +76,7 @@ func CommandSurface() []Group {
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: "create", Description: "Create a project", Permitted: has(perms, "project.create")},
{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")},
@@ -74,6 +84,30 @@ func CommandSurface() []Group {
{Name: "remove-member", Description: "Remove a project member", Permitted: has(perms, "project.manage_members")},
},
},
{
Name: "knowledge-base",
Description: "Manage knowledge bases (topics, categories, facts) and project links",
SubCommands: []Command{
{Name: "list", Description: "List knowledge bases", Permitted: has(perms, "knowledge-base.read")},
{Name: "get", Description: "Show a knowledge base by code", Permitted: has(perms, "knowledge-base.read")},
{Name: "tree", Description: "Show the full topic/category/fact tree", Permitted: has(perms, "knowledge-base.read")},
{Name: "topics", Description: "List topics in a knowledge base", Permitted: has(perms, "knowledge-base.read")},
{Name: "create", Description: "Create a knowledge base", Permitted: has(perms, "knowledge-base.create")},
{Name: "update", Description: "Update a knowledge base", Permitted: has(perms, "knowledge-base.update")},
{Name: "delete", Description: "Delete a knowledge base", Permitted: has(perms, "knowledge-base.delete")},
{Name: "link", Description: "Link a knowledge base to a project", Permitted: has(perms, "knowledge-base.update")},
{Name: "unlink", Description: "Unlink a knowledge base from a project", Permitted: has(perms, "knowledge-base.update")},
{Name: "add-topic", Description: "Add a topic", Permitted: has(perms, "knowledge-base.update")},
{Name: "update-topic", Description: "Update a topic", Permitted: has(perms, "knowledge-base.update")},
{Name: "delete-topic", Description: "Delete a topic", Permitted: has(perms, "knowledge-base.update")},
{Name: "add-category", Description: "Add a category", Permitted: has(perms, "knowledge-base.update")},
{Name: "update-category", Description: "Update a category", Permitted: has(perms, "knowledge-base.update")},
{Name: "delete-category", Description: "Delete a category (and its descendants)", Permitted: has(perms, "knowledge-base.update")},
{Name: "add-fact", Description: "Add a fact", Permitted: has(perms, "knowledge-base.update")},
{Name: "update-fact", Description: "Update a fact", Permitted: has(perms, "knowledge-base.update")},
{Name: "delete-fact", Description: "Delete a fact", Permitted: has(perms, "knowledge-base.update")},
},
},
{
Name: "milestone",
Description: "Manage milestones",
@@ -179,6 +213,16 @@ func CommandSurface() []Group {
{Name: "api-key", Description: "Manage monitor API keys", Permitted: has(perms, "monitor.manage")},
},
},
{
Name: "schedule-type",
Description: "Manage work/entertainment schedule types",
SubCommands: []Command{
{Name: "list", Description: "List schedule types", Permitted: has(perms, "schedule_type.read")},
{Name: "create", Description: "Create a schedule type", Permitted: has(perms, "schedule_type.manage")},
{Name: "delete", Description: "Delete a schedule type", Permitted: has(perms, "schedule_type.manage")},
},
},
{Name: "assign-schedule-type", Description: "Assign a schedule type to an agent: assign-schedule-type <agent-id> <type-name>", Permitted: has(perms, "schedule_type.manage")},
}
for i := range groups {
@@ -204,7 +248,12 @@ func loadPermissionState(token string) permissionState {
return permissionState{Known: false, Permissions: map[string]struct{}{}}
}
c := client.New(cfg.BaseURL, token)
// passmgr.GetToken() returns an api-key in padded-cell mode (provisioned
// by scripts/provision-hf-accounts.sh via `hf user reset-apikey`), so go
// through the X-API-Key path explicitly. client.New also auto-detects this
// nowadays, but the explicit call keeps the introspection path independent
// of that heuristic.
c := client.NewWithAPIKey(cfg.BaseURL, token)
data, err := c.Get("/auth/me/permissions")
if err != nil {
return permissionState{Known: false, Permissions: map[string]struct{}{}}
@@ -223,6 +272,10 @@ func loadPermissionState(token string) permissionState {
}
func has(state permissionState, perm string) bool {
// Test/development bypass: HF_TEST_MODE=1 grants all permissions
if os.Getenv("HF_TEST_MODE") == "1" {
return true
}
if !state.Known {
return false
}

View File

@@ -12,7 +12,7 @@ type RuntimeMode int
const (
// ManualMode requires explicit --token / --acc-mgr-token flags.
ManualMode RuntimeMode = iota
// PaddedCellMode resolves secrets via pass_mgr automatically.
// PaddedCellMode resolves secrets via secret-mgr automatically.
PaddedCellMode
)
@@ -21,11 +21,11 @@ var (
detectOnce sync.Once
)
// Detect checks whether pass_mgr is available and returns the runtime mode.
// Detect checks whether secret-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")
_, err := exec.LookPath("secret-mgr")
if err == nil {
detectedMode = PaddedCellMode
} else {

View File

@@ -1,4 +1,4 @@
// Package passmgr wraps calls to the pass_mgr binary for secret resolution.
// Package passmgr wraps calls to the secret-mgr binary for secret resolution.
package passmgr
import (
@@ -7,49 +7,49 @@ import (
"strings"
)
// GetSecret calls: pass_mgr get-secret [--public] --key <key>
// GetSecret calls: secret-mgr get-secret [--public] --key <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()
out, err := exec.Command("secret-mgr", args...).Output()
if err != nil {
return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err)
return "", fmt.Errorf("secret-mgr get-secret --key %s failed: %w", key, err)
}
return strings.TrimSpace(string(out)), nil
}
// SetSecret calls: pass_mgr set [--public] --key <key> --secret <secret>
// SetSecret calls: secret-mgr set [--public] --key <key> --secret <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)
if err := exec.Command("secret-mgr", args...).Run(); err != nil {
return fmt.Errorf("secret-mgr set --key %s failed: %w", key, err)
}
return nil
}
// GeneratePassword calls: pass_mgr generate --key <key> --username <username>
// GeneratePassword calls: secret-mgr generate --key <key> --username <username>
func GeneratePassword(key, username string) (string, error) {
args := []string{"generate", "--key", key, "--username", username}
out, err := exec.Command("pass_mgr", args...).Output()
out, err := exec.Command("secret-mgr", args...).Output()
if err != nil {
return "", fmt.Errorf("pass_mgr generate failed: %w", err)
return "", fmt.Errorf("secret-mgr generate failed: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// GetToken retrieves the normal hf-token via pass_mgr.
// GetToken retrieves the normal hf-token via secret-mgr.
func GetToken() (string, error) {
return GetSecret("hf-token", false)
}
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr.
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via secret-mgr.
func GetAccountManagerToken() (string, error) {
return GetSecret("hf-acc-mgr-token", true)
}