Compare commits

...

127 commits
v0.3.7 ... dev

Author SHA1 Message Date
Snider
c205be9b86 fix: migrate module paths from forge.lthn.ai to dappco.re
Some checks failed
Security Scan / security (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:11 +01:00
Virgil
20a2e77e19 fix(cli): restore glyph theme after stateful tests
Some checks failed
Security Scan / security (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 17:56:01 +00:00
Claude
6b321fe5c9 refactor(ax): Pass 1 AX compliance sweep — banned imports, naming, tests
Some checks are pending
Security Scan / security (push) Waiting to run
- Remove banned imports (fmt, log, errors, os, strings, path/filepath,
  encoding/json) from all cmd/ packages; replace with core.* primitives
  and cli.* wrappers
- Rename abbreviated variables (cfg→configuration, reg→registry,
  cmd→proc, c→toolCheck/checkBuilder, sb→builder, out→output,
  r→repo/reason, b→branchName) across config, doctor, pkgcmd, help
- Add usage-example comments to all exported functions in pkg/cli
  (strings.go, log.go, i18n.go)
- Add complete Good/Bad/Ugly test triads to all pkg/cli test files:
  new files for command, errors, frame_components, i18n, log, render,
  runtime, strings, utils; updated existing check, daemon, glyph,
  layout, output, ansi, commands, frame, prompt, stream, styles,
  tracker, tree

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:28:36 +00:00
Virgil
bfc47c8400 fix(cli): return early in live tracker when no pending tasks
Some checks are pending
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 17:16:57 +00:00
Virgil
cdae3a9ac5 fix(cli): route stream output through injected stdout writer
Some checks are pending
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 17:13:26 +00:00
Virgil
050ee5bd8f chore(cli): align exported API docs with usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
2026-04-02 17:10:02 +00:00
Virgil
8b345eba5b chore(core): verify cli RFC/AX compliance status
Some checks are pending
Security Scan / security (push) Waiting to run
2026-04-02 16:54:01 +00:00
Virgil
7b5f5e7181 chore(core): verify cli RFC/AX compliance
Some checks are pending
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 16:50:44 +00:00
3e0c9d7809 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#95) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
Some checks failed
Security Scan / security (push) Failing after 17s
2026-04-02 13:41:38 +00:00
Virgil
fcf5f9cfd5 fix(cli): make stdio routing injectable
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:39:56 +00:00
91ef8c02cf Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#94) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 21s
2026-04-02 13:26:01 +00:00
Virgil
b1afac56bb fix(cli): add explicit output setters
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:24:23 +00:00
d65aebd298 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#93) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 13:20:28 +00:00
Virgil
821f7d191d fix(cli): respect stdin override in Scanln
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:18:36 +00:00
d93504e94a Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#92) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 27s
2026-04-02 13:14:35 +00:00
Virgil
905dfae6b1 fix(cli): align multi-select empty input with docs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:13:18 +00:00
d146c73e43 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#91) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 26s
2026-04-02 13:08:49 +00:00
Virgil
be2e2db845 fix(cli): add safe stream capture helper
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 13:07:28 +00:00
d7c416d257 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#90) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 22s
2026-04-02 13:03:27 +00:00
Virgil
4e9b42e7d0 fix(cli): make check output width-aware
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:02:16 +00:00
e0aba4b863 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#89) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 23s
2026-04-02 12:57:54 +00:00
Virgil
aa07b4bbb1 fix(cli): align public API docs with AX
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:56:45 +00:00
53e9fc13be Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#88) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 25s
2026-04-02 12:52:36 +00:00
Virgil
63481f127c fix(cli): render glyph shortcodes in task tracker
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:51:00 +00:00
3ce53ed394 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#87) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 18s
2026-04-02 12:46:51 +00:00
Virgil
50d9158920 fix(cli): restore colors after ascii theme
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:45:24 +00:00
9a80a604f4 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#86) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 21s
2026-04-02 12:41:43 +00:00
Virgil
3862b7c032 fix(cli): clear chooser filters on empty input
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:40:30 +00:00
fb914b99c2 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#85) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 19s
2026-04-02 12:32:49 +00:00
Virgil
81be3b701e fix(cli): theme-aware semantic glyphs
Keep section headers, check skips, and layout separators aligned with the active glyph theme.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:31:44 +00:00
b5e67b2430 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#84) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 21s
2026-04-02 12:27:57 +00:00
Virgil
11ac2c62c6 fix(cli): render glyphs in static renderables
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:26:38 +00:00
048133c02f Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#83) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 18s
2026-04-02 11:35:24 +00:00
Virgil
e1edbc1f9b fix(cli): make tracker iterators snapshot-safe
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:34:19 +00:00
5761524570 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#82) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 35s
2026-04-02 11:30:21 +00:00
Virgil
c0cb67cada fix(cli): render glyph shortcodes in tables
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:29:19 +00:00
ba456e4560 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#81) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 25s
2026-04-02 11:26:08 +00:00
Virgil
07bea81d4a fix(cli): render glyphs in echo and progress
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:24:57 +00:00
0703a5727d Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#80) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 26s
2026-04-02 11:21:18 +00:00
Virgil
6192340ec0 fix(cli): route frame UI to stderr
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:19:48 +00:00
32e096c6e1 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#79) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 21s
2026-04-02 11:17:17 +00:00
Virgil
4f7a4c3a20 fix(cli): route interactive ui to stderr
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:16:05 +00:00
3cf13e751e Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#78) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 28s
2026-04-02 11:11:33 +00:00
Virgil
f8ba7be626 fix(pkgcmd): send remove blockers to stderr
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:10:24 +00:00
c02e88a6ff Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#77) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
Some checks failed
Security Scan / security (push) Has been cancelled
2026-04-02 11:06:38 +00:00
Virgil
f9bf2231e5 fix(cli): accept eof input in multi-select
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:05:34 +00:00
1cf8e17e1c Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#76) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 22s
2026-04-02 11:02:45 +00:00
Virgil
d59e6acd72 fix(cli): route prompt selection hints to stderr
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 11:01:41 +00:00
c6c07f0ee4 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#75) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 23s
2026-04-02 10:58:07 +00:00
Virgil
8a7567c705 fix(cli): send prompt recovery hints to stderr
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:56:52 +00:00
125d5e76a1 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#74) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 27s
2026-04-02 10:54:08 +00:00
Virgil
32b824a8a4 fix(cli): style prompt recovery hints
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:52:56 +00:00
5e663d6d94 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#73) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
Some checks failed
Security Scan / security (push) Has been cancelled
2026-04-02 10:49:55 +00:00
Virgil
4ec7341e76 fix(cli): surface eof for empty prompts
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:48:56 +00:00
2c837453fb Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#72) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
Some checks are pending
Security Scan / security (push) Waiting to run
2026-04-02 10:45:32 +00:00
Virgil
1242723ac1 fix(pkgcmd): remove packages from registry
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:44:28 +00:00
76bccc0526 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#71) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 33s
2026-04-02 10:38:37 +00:00
Virgil
207a38e236 fix(cli): improve prompt recovery hints
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 10:37:37 +00:00
893b6d0c09 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#70) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 23s
2026-04-02 10:33:53 +00:00
Virgil
904a5c057b fix(cli): remove hidden chooser fallback
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:33:00 +00:00
37fdcdb7b4 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#69) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 19s
2026-04-02 10:29:47 +00:00
Virgil
87513483e8 fix(cli): remove implicit chooser defaults
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:28:43 +00:00
83d649add0 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#68) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 10:24:59 +00:00
Virgil
817bdea525 fix(cli): make legacy selection errors actionable
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:24:05 +00:00
53b5552554 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#67) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 23s
2026-04-02 10:21:46 +00:00
Virgil
b8bfdcf731 fix(help): add next-step hints to help output
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:20:48 +00:00
60c9f92eca Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#66) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 29s
2026-04-02 10:16:09 +00:00
Virgil
c3f2d6abb7 fix(help): add recovery hint for empty searches
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:15:13 +00:00
742b1d2a9e Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#65) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 25s
2026-04-02 10:11:46 +00:00
Virgil
7bf060986d fix(help): add recovery hints to help lookups
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:10:56 +00:00
6c39c0f932 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#64) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 23s
2026-04-02 10:07:50 +00:00
Virgil
f71bdb3bf4 feat(cli): compile glyph shortcodes in rendered components
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 10:06:52 +00:00
be63660740 Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#63) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 28s
2026-04-02 06:47:01 +00:00
Virgil
b8f3c9698a fix(cli): make command registration snapshot-safe
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:45:31 +00:00
5a7335888c Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/go/cli/RFC.md fully. Find features des...' (#62) from agent/read---spec-code-core-go-cli-rfc-md-full into dev
All checks were successful
Security Scan / security (push) Successful in 24s
2026-04-02 06:39:47 +00:00
Virgil
88ec9264a9 fix(cli): strip ANSI from static frame output
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:38:45 +00:00
Virgil
5c8f08b60e fix(cli): harden legacy select helpers
All checks were successful
Security Scan / security (push) Successful in 21s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:34:25 +00:00
Virgil
aa537c89ca fix(cli): make styled helpers nil-safe
Some checks are pending
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:30:14 +00:00
Virgil
aa5c0f810a feat(help): add explicit search subcommand
Some checks are pending
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:26:19 +00:00
Virgil
a035cb2169 fix(cli): treat eof as empty multi-select
All checks were successful
Security Scan / security (push) Successful in 20s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:20:30 +00:00
Virgil
e96ea6d7c2 fix(cli): honor default selection in multi-select
All checks were successful
Security Scan / security (push) Successful in 22s
2026-04-02 06:15:59 +00:00
Virgil
43d4bbd2dc fix(cli): reprompt required prompts on empty input
All checks were successful
Security Scan / security (push) Successful in 23s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:10:37 +00:00
Virgil
a5142dea78 fix(cli): render glyphs in prompts and handle EOF
All checks were successful
Security Scan / security (push) Successful in 19s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:07:09 +00:00
Virgil
37310c7cbd fix(cli): avoid hanging prompts on EOF
All checks were successful
Security Scan / security (push) Successful in 24s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:01:35 +00:00
Virgil
f376372630 feat(help): show suggestions for missing topics
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 05:43:29 +00:00
Virgil
2a9177a30b feat(pkg): add json output for pkg update
All checks were successful
Security Scan / security (push) Successful in 18s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 05:38:12 +00:00
Virgil
e259ce323b fix(cli): make stream completion idempotent
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 05:30:17 +00:00
Virgil
323f408601 feat(cli): add ASCII table borders
All checks were successful
Security Scan / security (push) Successful in 23s
2026-04-02 05:26:20 +00:00
Virgil
8b30e80688 feat(cli): add ASCII glyph fallbacks for tree and tracker
All checks were successful
Security Scan / security (push) Successful in 21s
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 05:23:01 +00:00
Virgil
58f07603cd fix: allow pkg update without package args
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 05:16:51 +00:00
Virgil
e29b6e4889 fix(cli): make stream width handling rune-safe
Some checks are pending
Security Scan / security (push) Waiting to run
2026-04-02 05:13:07 +00:00
Virgil
cf9c068650 fix(cli): make width helpers rune-safe
All checks were successful
Security Scan / security (push) Successful in 22s
2026-04-02 05:09:09 +00:00
Virgil
dc30159392 fix(cli): render glyph shortcodes in output
All checks were successful
Security Scan / security (push) Successful in 22s
2026-04-02 05:02:35 +00:00
Virgil
cdc765611f fix(help): add HTTP serve subcommand
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 04:57:59 +00:00
Virgil
7dadf41670 feat(cli): add string-to-string flag helpers
All checks were successful
Security Scan / security (push) Successful in 21s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:54:36 +00:00
Virgil
181d9546b4 feat(help): show topic previews
All checks were successful
Security Scan / security (push) Successful in 17s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:51:19 +00:00
Virgil
419e7f745b feat(cli): add formatted security log helper
All checks were successful
Security Scan / security (push) Successful in 19s
2026-04-02 04:47:42 +00:00
Virgil
4d127de05f feat(cli): add security log helper
All checks were successful
Security Scan / security (push) Successful in 21s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:43:57 +00:00
Virgil
9fd432aed3 fix(cli): support comma and range multi-select input
All checks were successful
Security Scan / security (push) Successful in 23s
2026-04-02 04:40:23 +00:00
Virgil
b1850124de fix(cli): accept comma-separated multi-select input
All checks were successful
Security Scan / security (push) Successful in 19s
2026-04-02 04:36:07 +00:00
Virgil
02d4ee74e6 feat(cli): add string array flag helpers
All checks were successful
Security Scan / security (push) Successful in 22s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:31:29 +00:00
Virgil
12496ba57c feat(cli): add external daemon stop helper
All checks were successful
Security Scan / security (push) Successful in 19s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:27:42 +00:00
Virgil
a2f27b9af4 feat(cli): add daemon lifecycle helper
All checks were successful
Security Scan / security (push) Successful in 20s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:22:22 +00:00
Virgil
f13c3bf095 fix: add go check to doctor
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 04:17:35 +00:00
Virgil
96aef54baf feat(cli): add filterable generic selection
All checks were successful
Security Scan / security (push) Successful in 25s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:13:45 +00:00
Virgil
4e258c80b1 feat(cli): add runtime run helpers
Some checks are pending
Security Scan / security (push) Waiting to run
2026-04-02 04:01:52 +00:00
Virgil
9c64f239a8 fix(cli): respect stdin overrides in prompts
All checks were successful
Security Scan / security (push) Successful in 20s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:57:06 +00:00
Virgil
c6fae794b3 fix(cli): load locale sources during registration
All checks were successful
Security Scan / security (push) Successful in 24s
2026-04-02 03:53:21 +00:00
Virgil
fcadba08b1 feat(pkg): support install refs in shorthand
Some checks are pending
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:47:53 +00:00
Virgil
27e44f069a fix(cli): reset stdin on nil override
All checks were successful
Security Scan / security (push) Successful in 20s
2026-04-02 03:42:16 +00:00
Virgil
32342dfd31 feat(pkg): accept install repo shorthand
All checks were successful
Security Scan / security (push) Successful in 20s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:36:43 +00:00
Virgil
d84d8cc838 feat: add persistent CLI flag helpers
All checks were successful
Security Scan / security (push) Successful in 17s
2026-04-01 09:43:59 +00:00
Virgil
4c072f9463 feat(pkg): show full repo names in search results
All checks were successful
Security Scan / security (push) Successful in 16s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:26:25 +00:00
Virgil
04d244425b feat(pkg): honor search limit after cache
All checks were successful
Security Scan / security (push) Successful in 14s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:24:10 +00:00
Virgil
d50b006af9 feat(help): show snippets in search results
Some checks are pending
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:21:03 +00:00
Virgil
9aff00de1e feat(pkg): add JSON output for pkg outdated
All checks were successful
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 20:07:59 +00:00
Virgil
10de071704 feat(pkg): add JSON output for package search
All checks were successful
Security Scan / security (push) Successful in 19s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:59:57 +00:00
Virgil
7fda1cf320 fix(pkg): accept positional search patterns
All checks were successful
Security Scan / security (push) Successful in 18s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:54:26 +00:00
Virgil
0595bf7e0f feat(pkg): show search repo metadata
All checks were successful
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:50:59 +00:00
Virgil
1dd401fa04 feat(pkg): add json format for package list
All checks were successful
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:45:42 +00:00
Virgil
c67582c76b fix(help): make help command AX-friendly
All checks were successful
Security Scan / security (push) Successful in 21s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:31:23 +00:00
Virgil
c74524bc58 chore: align check output with CLI wrappers
Some checks failed
Deploy / build (push) Failing after 5s
Security Scan / security (push) Successful in 21s
2026-03-30 06:15:24 +00:00
e177418c90 Merge pull request '[agent/claude] Fix core/cli to unblock core/agent build. Remove core.WithNa...' (#8) from agent/fix-core-cli-to-unblock-core-agent-build into main
Some checks failed
Deploy / build (push) Failing after 5s
Security Scan / security (push) Successful in 12s
2026-03-21 22:56:27 +00:00
Snider
bcbc25974e fix(cli): resolve build errors and clean up stale API references
All checks were successful
Security Scan / security (pull_request) Successful in 18s
Remove orphaned daemon_cmd_test.go referencing undefined AddDaemonCommand/
DaemonCommandConfig symbols. Update docs to reflect current API types
(CommandSetup, core.Service). Restore .gitignore entries for dist/, .env,
and coverage artefacts. Extract appendLocales helper to deduplicate locale
registration. Fix test reset to clear registeredLocales for proper isolation.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 22:56:10 +00:00
Snider
92da6e8a73 refactor: migrate to dappco.re/go/core + Options{} API
Some checks failed
Deploy / build (push) Failing after 6s
Security Scan / security (push) Successful in 19s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 20:01:25 +00:00
Snider
542698c579 fix: update for CoreGO API — s.core.App → s.core.App().Runtime
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-18 12:19:03 +00:00
Snider
85eaceec05 chore: sync dependencies for v0.3.8
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:55:08 +00:00
88 changed files with 4374 additions and 1097 deletions

44
.gitignore vendored
View file

@ -1,28 +1,28 @@
wails3
.task
vendor/
.idea
node_modules/
.idea/
.vscode/
.DS_Store
*.log
.env
.env.*.local
.core/
# Build artefacts
dist/
bin/
/core
/cli
# Go
vendor/
go.work.sum
coverage/
coverage.out
coverage.html
*.cache
/coverage.txt
bin/
dist/
tasks
/cli
/core
local.test
/i18n-validate
.angular/
coverage.txt
patch_cov.*
go.work.sum
.kb
.core/
.idea/
# Environment / secrets
.env
.env.*.local
# OS / tooling
.task
*.cache
node_modules/

View file

@ -6,6 +6,8 @@ import (
)
// AddConfigCommands registers the 'config' command group and all subcommands.
//
// config.AddConfigCommands(rootCmd)
func AddConfigCommands(root *cli.Command) {
configCmd := cli.NewGroup("config", "Manage configuration", "")
root.AddCommand(configCmd)
@ -17,9 +19,9 @@ func AddConfigCommands(root *cli.Command) {
}
func loadConfig() (*config.Config, error) {
cfg, err := config.New()
configuration, err := config.New()
if err != nil {
return nil, cli.Wrap(err, "failed to load config")
}
return cfg, nil
return configuration, nil
}

View file

@ -1,8 +1,6 @@
package config
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
)
@ -10,17 +8,17 @@ func addGetCommand(parent *cli.Command) {
cmd := cli.NewCommand("get", "Get a configuration value", "", func(cmd *cli.Command, args []string) error {
key := args[0]
cfg, err := loadConfig()
configuration, err := loadConfig()
if err != nil {
return err
}
var value any
if err := cfg.Get(key, &value); err != nil {
if err := configuration.Get(key, &value); err != nil {
return cli.Err("key not found: %s", key)
}
fmt.Println(value)
cli.Println("%v", value)
return nil
})

View file

@ -1,7 +1,6 @@
package config
import (
"fmt"
"maps"
"forge.lthn.ai/core/cli/pkg/cli"
@ -10,23 +9,23 @@ import (
func addListCommand(parent *cli.Command) {
cmd := cli.NewCommand("list", "List all configuration values", "", func(cmd *cli.Command, args []string) error {
cfg, err := loadConfig()
configuration, err := loadConfig()
if err != nil {
return err
}
all := maps.Collect(cfg.All())
all := maps.Collect(configuration.All())
if len(all) == 0 {
cli.Dim("No configuration values set")
return nil
}
out, err := yaml.Marshal(all)
output, err := yaml.Marshal(all)
if err != nil {
return cli.Wrap(err, "failed to format config")
}
fmt.Print(string(out))
cli.Print("%s", string(output))
return nil
})

View file

@ -1,19 +1,17 @@
package config
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
)
func addPathCommand(parent *cli.Command) {
cmd := cli.NewCommand("path", "Show the configuration file path", "", func(cmd *cli.Command, args []string) error {
cfg, err := loadConfig()
configuration, err := loadConfig()
if err != nil {
return err
}
fmt.Println(cfg.Path())
cli.Println("%s", configuration.Path())
return nil
})

View file

@ -9,12 +9,12 @@ func addSetCommand(parent *cli.Command) {
key := args[0]
value := args[1]
cfg, err := loadConfig()
configuration, err := loadConfig()
if err != nil {
return err
}
if err := cfg.Set(key, value); err != nil {
if err := configuration.Set(key, value); err != nil {
return cli.Wrap(err, "failed to set config value")
}

View file

@ -2,8 +2,8 @@ package doctor
import (
"os/exec"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
)
@ -26,6 +26,13 @@ func requiredChecks() []check {
args: []string{"--version"},
versionFlag: "--version",
},
{
name: i18n.T("cmd.doctor.check.go.name"),
description: i18n.T("cmd.doctor.check.go.description"),
command: "go",
args: []string{"version"},
versionFlag: "version",
},
{
name: i18n.T("cmd.doctor.check.gh.name"),
description: i18n.T("cmd.doctor.check.gh.description"),
@ -84,18 +91,20 @@ func optionalChecks() []check {
}
}
// runCheck executes a tool check and returns success status and version info
func runCheck(c check) (bool, string) {
cmd := exec.Command(c.command, c.args...)
output, err := cmd.CombinedOutput()
// runCheck executes a tool check and returns success status and version info.
//
// ok, version := runCheck(check{command: "git", args: []string{"--version"}})
func runCheck(toolCheck check) (bool, string) {
proc := exec.Command(toolCheck.command, toolCheck.args...)
output, err := proc.CombinedOutput()
if err != nil {
return false, ""
}
// Extract first line as version
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
// Extract first line as version info.
lines := core.Split(core.Trim(string(output)), "\n")
if len(lines) > 0 {
return true, strings.TrimSpace(lines[0])
return true, core.Trim(lines[0])
}
return true, ""
}

View file

@ -0,0 +1,22 @@
package doctor
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequiredChecksIncludesGo(t *testing.T) {
checks := requiredChecks()
var found bool
for _, c := range checks {
if c.command == "go" {
found = true
assert.Equal(t, "version", c.versionFlag)
break
}
}
assert.True(t, found, "required checks should include the Go compiler")
}

View file

@ -16,6 +16,8 @@ import (
)
// AddDoctorCommands registers the 'doctor' command and all subcommands.
//
// doctor.AddDoctorCommands(rootCmd)
func AddDoctorCommands(root *cobra.Command) {
doctorCmd.Short = i18n.T("cmd.doctor.short")
doctorCmd.Long = i18n.T("cmd.doctor.long")

View file

@ -2,9 +2,6 @@
package doctor
import (
"errors"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
@ -32,72 +29,72 @@ func init() {
}
func runDoctor(verbose bool) error {
fmt.Println(i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
fmt.Println()
cli.Println("%s", i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
cli.Blank()
var passed, failed, optional int
// Check required tools
fmt.Println(i18n.T("cmd.doctor.required"))
for _, c := range requiredChecks() {
ok, version := runCheck(c)
cli.Println("%s", i18n.T("cmd.doctor.required"))
for _, toolCheck := range requiredChecks() {
ok, version := runCheck(toolCheck)
if ok {
if verbose {
fmt.Println(formatCheckResult(true, c.name, version))
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
} else {
fmt.Println(formatCheckResult(true, c.name, ""))
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
}
passed++
} else {
fmt.Printf(" %s %s - %s\n", errorStyle.Render(cli.Glyph(":cross:")), c.name, c.description)
cli.Println(" %s %s - %s", errorStyle.Render(cli.Glyph(":cross:")), toolCheck.name, toolCheck.description)
failed++
}
}
// Check optional tools
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.optional"))
for _, c := range optionalChecks() {
ok, version := runCheck(c)
cli.Println("\n%s", i18n.T("cmd.doctor.optional"))
for _, toolCheck := range optionalChecks() {
ok, version := runCheck(toolCheck)
if ok {
if verbose {
fmt.Println(formatCheckResult(true, c.name, version))
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
} else {
fmt.Println(formatCheckResult(true, c.name, ""))
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
}
passed++
} else {
fmt.Printf(" %s %s - %s\n", dimStyle.Render(cli.Glyph(":skip:")), c.name, dimStyle.Render(c.description))
cli.Println(" %s %s - %s", dimStyle.Render(cli.Glyph(":skip:")), toolCheck.name, dimStyle.Render(toolCheck.description))
optional++
}
}
// Check GitHub access
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.github"))
cli.Println("\n%s", i18n.T("cmd.doctor.github"))
if checkGitHubSSH() {
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
} else {
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
failed++
}
if checkGitHubCLI() {
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
} else {
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
failed++
}
// Check workspace
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.workspace"))
cli.Println("\n%s", i18n.T("cmd.doctor.workspace"))
checkWorkspace()
// Summary
fmt.Println()
cli.Blank()
if failed > 0 {
cli.Error(i18n.T("cmd.doctor.issues", map[string]any{"Count": failed}))
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.install_missing"))
cli.Println("\n%s", i18n.T("cmd.doctor.install_missing"))
printInstallInstructions()
return errors.New(i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
return cli.Err("%s", i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
}
cli.Success(i18n.T("cmd.doctor.ready"))
@ -105,16 +102,16 @@ func runDoctor(verbose bool) error {
}
func formatCheckResult(ok bool, name, detail string) string {
check := cli.Check(name)
checkBuilder := cli.Check(name)
if ok {
check.Pass()
checkBuilder.Pass()
} else {
check.Fail()
checkBuilder.Fail()
}
if detail != "" {
check.Message(detail)
checkBuilder.Message(detail)
} else {
check.Message("")
checkBuilder.Message("")
}
return check.String()
return checkBuilder.String()
}

View file

@ -1,31 +1,29 @@
package doctor
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
io "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
)
// checkGitHubSSH checks if SSH keys exist for GitHub access
// checkGitHubSSH checks if SSH keys exist for GitHub access.
// Returns true if any standard SSH key file exists in ~/.ssh/.
func checkGitHubSSH() bool {
// Just check if SSH keys exist - don't try to authenticate
// (key might be locked/passphrase protected)
home, err := os.UserHomeDir()
if err != nil {
return false
}
sshDir := filepath.Join(home, ".ssh")
sshDirectory := core.Path(home, ".ssh")
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
for _, key := range keyPatterns {
keyPath := filepath.Join(sshDir, key)
for _, keyName := range keyPatterns {
keyPath := core.Path(sshDirectory, keyName)
if _, err := os.Stat(keyPath); err == nil {
return true
}
@ -34,46 +32,46 @@ func checkGitHubSSH() bool {
return false
}
// checkGitHubCLI checks if the GitHub CLI is authenticated
// checkGitHubCLI checks if the GitHub CLI is authenticated.
// Returns true when 'gh auth status' output contains "Logged in to".
func checkGitHubCLI() bool {
cmd := exec.Command("gh", "auth", "status")
output, _ := cmd.CombinedOutput()
// Check for any successful login (even if there's also a failing token)
return strings.Contains(string(output), "Logged in to")
proc := exec.Command("gh", "auth", "status")
output, _ := proc.CombinedOutput()
return core.Contains(string(output), "Logged in to")
}
// checkWorkspace checks for repos.yaml and counts cloned repos
// checkWorkspace checks for repos.yaml and counts cloned repos.
func checkWorkspace() {
registryPath, err := repos.FindRegistry(io.Local)
if err == nil {
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
reg, err := repos.LoadRegistry(io.Local, registryPath)
registry, err := repos.LoadRegistry(io.Local, registryPath)
if err == nil {
basePath := reg.BasePath
basePath := registry.BasePath
if basePath == "" {
basePath = "./packages"
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
if strings.HasPrefix(basePath, "~/") {
if core.HasPrefix(basePath, "~/") {
home, _ := os.UserHomeDir()
basePath = filepath.Join(home, basePath[2:])
basePath = core.Path(home, basePath[2:])
}
// Count existing repos
allRepos := reg.List()
// Count existing repos.
allRepos := registry.List()
var cloned int
for _, repo := range allRepos {
repoPath := filepath.Join(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
repoPath := core.Path(basePath, repo.Name)
if _, err := os.Stat(core.Path(repoPath, ".git")); err == nil {
cloned++
}
}
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
}
} else {
fmt.Printf(" %s %s\n", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
cli.Println(" %s %s", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
}
}

View file

@ -1,26 +1,26 @@
package doctor
import (
"fmt"
"runtime"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
// printInstallInstructions prints OS-specific installation instructions
// printInstallInstructions prints operating-system-specific installation instructions.
func printInstallInstructions() {
switch runtime.GOOS {
case "darwin":
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos_cask"))
cli.Println(" %s", i18n.T("cmd.doctor.install_macos"))
cli.Println(" %s", i18n.T("cmd.doctor.install_macos_cask"))
case "linux":
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_header"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_git"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_gh"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_php"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_node"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_pnpm"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_header"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_git"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_gh"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_php"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_node"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_pnpm"))
default:
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_other"))
cli.Println(" %s", i18n.T("cmd.doctor.install_other"))
}
}

View file

@ -74,9 +74,10 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oasdiff/oasdiff v1.12.1 // indirect
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
github.com/oasdiff/kin-openapi v0.136.1 // indirect
github.com/oasdiff/oasdiff v1.12.3 // indirect
github.com/oasdiff/yaml v0.0.1 // indirect
github.com/oasdiff/yaml3 v0.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect

View file

@ -159,12 +159,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oasdiff/oasdiff v1.12.1 h1:wnvBQS/WSqGqH23u1Jo3XVaF5y5X67TC5znSiy5nIug=
github.com/oasdiff/oasdiff v1.12.1/go.mod h1:4l8lF8SkdyiBVpa7AH3xc+oyDDXS1QTegX25mBS11/E=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/oasdiff/kin-openapi v0.136.1 h1:x1G9doDyPcagCNXDcMK5dt5yAmIgsSCiK7F5gPUiQdM=
github.com/oasdiff/kin-openapi v0.136.1/go.mod h1:BMeaLn+GmFJKtHJ31JrgXFt91eZi/q+Og4tr7sq0BzI=
github.com/oasdiff/oasdiff v1.12.3 h1:eUzJ/AiyyCY1KwUZPv7fosgDyETacIZbFesJrRz+QdY=
github.com/oasdiff/oasdiff v1.12.3/go.mod h1:ApEJGlkuRdrcBgTE4ioicwIM7nzkxPqLPPvcB5AytQ0=
github.com/oasdiff/yaml v0.0.1 h1:dPrn0F2PJ7HdzHPndJkArvB2Fw0cwgFdVUKCEkoFuds=
github.com/oasdiff/yaml v0.0.1/go.mod h1:r8bgVgpWT5iIN/AgP0GljFvB6CicK+yL1nIAbm+8/QQ=
github.com/oasdiff/yaml3 v0.0.1 h1:kReOSraQLTxuuGNX9aNeJ7tcsvUB2MS+iupdUrWe4Z0=
github.com/oasdiff/yaml3 v0.0.1/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=

View file

@ -1,12 +1,13 @@
package help
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-help"
)
// AddHelpCommands registers the help command and subcommands.
//
// help.AddHelpCommands(rootCmd)
func AddHelpCommands(root *cli.Command) {
var searchFlag string
@ -19,28 +20,28 @@ func AddHelpCommands(root *cli.Command) {
if searchFlag != "" {
results := catalog.Search(searchFlag)
if len(results) == 0 {
fmt.Println("No topics found.")
cli.Println("No topics found.")
return
}
fmt.Println("Search Results:")
for _, res := range results {
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title)
cli.Println("Search Results:")
for _, result := range results {
cli.Println(" %s - %s", result.Topic.ID, result.Topic.Title)
}
return
}
if len(args) == 0 {
topics := catalog.List()
fmt.Println("Available Help Topics:")
for _, t := range topics {
fmt.Printf(" %s - %s\n", t.ID, t.Title)
cli.Println("Available Help Topics:")
for _, topic := range topics {
cli.Println(" %s - %s", topic.ID, topic.Title)
}
return
}
topic, err := catalog.Get(args[0])
if err != nil {
fmt.Printf("Error: %v\n", err)
cli.Errorf("Error: %v", err)
return
}
@ -52,11 +53,9 @@ func AddHelpCommands(root *cli.Command) {
root.AddCommand(helpCmd)
}
func renderTopic(t *help.Topic) {
// Simple ANSI rendering for now
// Use explicit ANSI codes or just print
fmt.Printf("\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title
fmt.Println("----------------------------------------")
fmt.Println(t.Content)
fmt.Println()
func renderTopic(topic *help.Topic) {
cli.Println("\n%s", cli.TitleStyle.Render(topic.Title))
cli.Println("----------------------------------------")
cli.Println("%s", topic.Content)
cli.Blank()
}

241
cmd/core/help/cmd_test.go Normal file
View file

@ -0,0 +1,241 @@
package help
import (
"bytes"
"io"
"os"
"testing"
"forge.lthn.ai/core/cli/pkg/cli"
gohelp "forge.lthn.ai/core/go-help"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func captureOutput(t *testing.T, fn func()) string {
t.Helper()
oldOut := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() {
os.Stdout = oldOut
}()
fn()
require.NoError(t, w.Close())
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
return buf.String()
}
func newHelpCommand(t *testing.T) *cli.Command {
t.Helper()
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, err := root.Find([]string{"help"})
require.NoError(t, err)
return cmd
}
func searchableHelpQuery(t *testing.T) string {
t.Helper()
catalog := gohelp.DefaultCatalog()
for _, candidate := range []string{"configuration", "docs", "search", "topic", "help"} {
if _, err := catalog.Get(candidate); err == nil {
continue
}
if len(catalog.Search(candidate)) > 0 {
return candidate
}
}
t.Skip("no suitable query found with suggestions")
return ""
}
func TestAddHelpCommands_Good(t *testing.T) {
cmd := newHelpCommand(t)
topics := gohelp.DefaultCatalog().List()
require.NotEmpty(t, topics)
out := captureOutput(t, func() {
err := cmd.RunE(cmd, nil)
require.NoError(t, err)
})
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
assert.Contains(t, out, topics[0].ID)
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search <topic>")
}
func TestAddHelpCommands_Good_Serve(t *testing.T) {
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, err := root.Find([]string{"help", "serve"})
require.NoError(t, err)
require.NotNil(t, cmd)
oldStart := startHelpServer
defer func() { startHelpServer = oldStart }()
var gotAddr string
startHelpServer = func(catalog *gohelp.Catalog, addr string) error {
require.NotNil(t, catalog)
gotAddr = addr
return nil
}
require.NoError(t, cmd.Flags().Set("addr", "127.0.0.1:9090"))
err = cmd.RunE(cmd, nil)
require.NoError(t, err)
assert.Equal(t, "127.0.0.1:9090", gotAddr)
}
func TestAddHelpCommands_Good_Search(t *testing.T) {
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, err := root.Find([]string{"help", "search"})
require.NoError(t, err)
require.NotNil(t, cmd)
query := searchableHelpQuery(t)
require.NoError(t, cmd.Flags().Set("query", query))
out := captureOutput(t, func() {
err := cmd.RunE(cmd, nil)
require.NoError(t, err)
})
assert.Contains(t, out, "SEARCH RESULTS")
assert.Contains(t, out, query)
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search")
}
func TestRenderSearchResults_Good(t *testing.T) {
out := captureOutput(t, func() {
err := renderSearchResults([]*gohelp.SearchResult{
{
Topic: &gohelp.Topic{
ID: "config",
Title: "Configuration",
},
Snippet: "Core is configured via environment variables.",
},
}, "config")
require.NoError(t, err)
})
assert.Contains(t, out, "SEARCH RESULTS")
assert.Contains(t, out, "config - Configuration")
assert.Contains(t, out, "Core is configured via environment variables.")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search \"config\"")
}
func TestRenderTopicList_Good(t *testing.T) {
out := captureOutput(t, func() {
err := renderTopicList([]*gohelp.Topic{
{
ID: "config",
Title: "Configuration",
Content: "# Configuration\n\nCore is configured via environment variables.\n\nMore details follow.",
},
})
require.NoError(t, err)
})
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
assert.Contains(t, out, "config - Configuration")
assert.Contains(t, out, "Core is configured via environment variables.")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search <topic>")
}
func TestRenderTopic_Good(t *testing.T) {
out := captureOutput(t, func() {
renderTopic(&gohelp.Topic{
ID: "config",
Title: "Configuration",
Content: "Core is configured via environment variables.",
})
})
assert.Contains(t, out, "Configuration")
assert.Contains(t, out, "Core is configured via environment variables.")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search \"config\"")
}
func TestAddHelpCommands_Bad(t *testing.T) {
t.Run("missing search results", func(t *testing.T) {
cmd := newHelpCommand(t)
require.NoError(t, cmd.Flags().Set("search", "zzzyyyxxx"))
out := captureOutput(t, func() {
err := cmd.RunE(cmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no help topics matched")
})
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help")
assert.Contains(t, out, "core help search")
})
t.Run("missing topic without suggestions shows hints", func(t *testing.T) {
cmd := newHelpCommand(t)
out := captureOutput(t, func() {
err := cmd.RunE(cmd, []string{"definitely-not-a-real-topic"})
require.Error(t, err)
assert.Contains(t, err.Error(), "help topic")
})
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help")
})
t.Run("missing search query", func(t *testing.T) {
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, findErr := root.Find([]string{"help", "search"})
require.NoError(t, findErr)
require.NotNil(t, cmd)
var runErr error
out := captureOutput(t, func() {
runErr = cmd.RunE(cmd, nil)
})
require.Error(t, runErr)
assert.Contains(t, runErr.Error(), "help search query is required")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help")
})
t.Run("missing topic shows suggestions when available", func(t *testing.T) {
query := searchableHelpQuery(t)
cmd := newHelpCommand(t)
out := captureOutput(t, func() {
err := cmd.RunE(cmd, []string{query})
require.Error(t, err)
assert.Contains(t, err.Error(), "help topic")
})
assert.Contains(t, out, "SEARCH RESULTS")
})
}

View file

@ -2,21 +2,16 @@ package pkgcmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
import (
"errors"
)
var (
installTargetDir string
installAddToReg bool
@ -30,7 +25,7 @@ func addPkgInstallCommand(parent *cobra.Command) {
Long: i18n.T("cmd.pkg.install.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgInstall(args[0], installTargetDir, installAddToReg)
},
@ -42,119 +37,119 @@ func addPkgInstallCommand(parent *cobra.Command) {
parent.AddCommand(installCmd)
}
func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
func runPkgInstall(repoArg, targetDirectory string, addToRegistry bool) error {
ctx := context.Background()
// Parse org/repo
parts := strings.Split(repoArg, "/")
// Parse org/repo argument.
parts := core.Split(repoArg, "/")
if len(parts) != 2 {
return errors.New(i18n.T("cmd.pkg.error.invalid_repo_format"))
return cli.Err(i18n.T("cmd.pkg.error.invalid_repo_format"))
}
org, repoName := parts[0], parts[1]
// Determine target directory
if targetDir == "" {
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
if reg, err := repos.LoadRegistry(coreio.Local, regPath); err == nil {
targetDir = reg.BasePath
if targetDir == "" {
targetDir = "./packages"
// Determine target directory from registry or default.
if targetDirectory == "" {
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
if registry, err := repos.LoadRegistry(coreio.Local, registryPath); err == nil {
targetDirectory = registry.BasePath
if targetDirectory == "" {
targetDirectory = "./packages"
}
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(regPath), targetDir)
if !core.PathIsAbs(targetDirectory) {
targetDirectory = core.Path(core.PathDir(registryPath), targetDirectory)
}
}
}
if targetDir == "" {
targetDir = "."
if targetDirectory == "" {
targetDirectory = "."
}
}
if strings.HasPrefix(targetDir, "~/") {
if core.HasPrefix(targetDirectory, "~/") {
home, _ := os.UserHomeDir()
targetDir = filepath.Join(home, targetDir[2:])
targetDirectory = core.Path(home, targetDirectory[2:])
}
repoPath := filepath.Join(targetDir, repoName)
repoPath := core.Path(targetDirectory, repoName)
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
if coreio.Local.Exists(core.Path(repoPath, ".git")) {
cli.Println("%s %s", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
return nil
}
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err)
if err := coreio.Local.EnsureDir(targetDirectory); err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.create", "directory"))
}
fmt.Printf("%s %s/%s\n", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), repoPath)
fmt.Println()
cli.Println("%s %s/%s", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
cli.Println("%s %s", dimStyle.Render(i18n.Label("target")), repoPath)
cli.Blank()
fmt.Printf(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
cli.Print(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
err := gitClone(ctx, org, repoName, repoPath)
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error()))
cli.Println("%s", errorStyle.Render("✗ "+err.Error()))
return err
}
fmt.Printf("%s\n", successStyle.Render("✓"))
cli.Println("%s", successStyle.Render("✓"))
if addToRegistry {
if err := addToRegistryFile(org, repoName); err != nil {
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
cli.Println(" %s %s: %s", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
} else {
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
}
}
fmt.Println()
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
cli.Blank()
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
return nil
}
func addToRegistryFile(org, repoName string) error {
regPath, err := repos.FindRegistry(coreio.Local)
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return err
}
if _, exists := reg.Get(repoName); exists {
if _, exists := registry.Get(repoName); exists {
return nil
}
content, err := coreio.Local.Read(regPath)
content, err := coreio.Local.Read(registryPath)
if err != nil {
return err
}
repoType := detectRepoType(repoName)
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
entry := cli.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType)
content += entry
return coreio.Local.Write(regPath, content)
return coreio.Local.Write(registryPath, content)
}
func detectRepoType(name string) string {
lower := strings.ToLower(name)
if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") {
lowerName := core.Lower(name)
if core.Contains(lowerName, "-mod-") || core.HasSuffix(lowerName, "-mod") {
return "module"
}
if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") {
if core.Contains(lowerName, "-plug-") || core.HasSuffix(lowerName, "-plug") {
return "plugin"
}
if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") {
if core.Contains(lowerName, "-services-") || core.HasSuffix(lowerName, "-services") {
return "service"
}
if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") {
if core.Contains(lowerName, "-website-") || core.HasSuffix(lowerName, "-website") {
return "website"
}
if strings.HasPrefix(lower, "core-") {
if core.HasPrefix(lowerName, "core-") {
return "package"
}
return "package"

View file

@ -0,0 +1,114 @@
package pkgcmd
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunPkgInstall_AllowsRepoShorthand_Good(t *testing.T) {
tmp := t.TempDir()
targetDir := filepath.Join(tmp, "packages")
originalGitClone := gitClone
t.Cleanup(func() {
gitClone = originalGitClone
})
var gotOrg, gotRepo, gotPath string
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
gotOrg = org
gotRepo = repoName
gotPath = repoPath
return nil
}
err := runPkgInstall("core-api", targetDir, false)
require.NoError(t, err)
assert.Equal(t, "host-uk", gotOrg)
assert.Equal(t, "core-api", gotRepo)
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
_, err = os.Stat(targetDir)
require.NoError(t, err)
}
func TestRunPkgInstall_AllowsExplicitOrgRepo_Good(t *testing.T) {
tmp := t.TempDir()
targetDir := filepath.Join(tmp, "packages")
originalGitClone := gitClone
t.Cleanup(func() {
gitClone = originalGitClone
})
var gotOrg, gotRepo, gotPath string
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
gotOrg = org
gotRepo = repoName
gotPath = repoPath
return nil
}
err := runPkgInstall("myorg/core-api", targetDir, false)
require.NoError(t, err)
assert.Equal(t, "myorg", gotOrg)
assert.Equal(t, "core-api", gotRepo)
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
}
func TestRunPkgInstall_InvalidRepoFormat_Bad(t *testing.T) {
err := runPkgInstall("a/b/c", t.TempDir(), false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid repo format")
}
func TestParsePkgInstallSource_Good(t *testing.T) {
t.Run("default org and repo", func(t *testing.T) {
org, repo, ref, err := parsePkgInstallSource("core-api")
require.NoError(t, err)
assert.Equal(t, "host-uk", org)
assert.Equal(t, "core-api", repo)
assert.Empty(t, ref)
})
t.Run("explicit org and ref", func(t *testing.T) {
org, repo, ref, err := parsePkgInstallSource("myorg/core-api@v1.2.3")
require.NoError(t, err)
assert.Equal(t, "myorg", org)
assert.Equal(t, "core-api", repo)
assert.Equal(t, "v1.2.3", ref)
})
}
func TestRunPkgInstall_WithRef_UsesRefClone_Good(t *testing.T) {
tmp := t.TempDir()
targetDir := filepath.Join(tmp, "packages")
originalGitCloneRef := gitCloneRef
t.Cleanup(func() {
gitCloneRef = originalGitCloneRef
})
var gotOrg, gotRepo, gotPath, gotRef string
gitCloneRef = func(_ context.Context, org, repoName, repoPath, ref string) error {
gotOrg = org
gotRepo = repoName
gotPath = repoPath
gotRef = ref
return nil
}
err := runPkgInstall("myorg/core-api@v1.2.3", targetDir, false)
require.NoError(t, err)
assert.Equal(t, "myorg", gotOrg)
assert.Equal(t, "core-api", gotRepo)
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
assert.Equal(t, "v1.2.3", gotRef)
}

View file

@ -1,12 +1,10 @@
package pkgcmd
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
@ -28,36 +26,36 @@ func addPkgListCommand(parent *cobra.Command) {
}
func runPkgList() error {
regPath, err := repos.FindRegistry(coreio.Local)
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := reg.BasePath
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
allRepos := reg.List()
allRepos := registry.List()
if len(allRepos) == 0 {
fmt.Println(i18n.T("cmd.pkg.list.no_packages"))
cli.Println("%s", i18n.T("cmd.pkg.list.no_packages"))
return nil
}
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
cli.Println("%s\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
var installed, missing int
for _, r := range allRepos {
repoPath := filepath.Join(basePath, r.Name)
exists := coreio.Local.Exists(filepath.Join(repoPath, ".git"))
for _, repo := range allRepos {
repoPath := core.Path(basePath, repo.Name)
exists := coreio.Local.Exists(core.Path(repoPath, ".git"))
if exists {
installed++
} else {
@ -69,23 +67,23 @@ func runPkgList() error {
status = dimStyle.Render("○")
}
desc := r.Description
if len(desc) > 40 {
desc = desc[:37] + "..."
description := repo.Description
if len(description) > 40 {
description = description[:37] + "..."
}
if desc == "" {
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
if description == "" {
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name))
fmt.Printf(" %s\n", desc)
cli.Println(" %s %s", status, repoNameStyle.Render(repo.Name))
cli.Println(" %s", description)
}
fmt.Println()
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
cli.Blank()
cli.Println("%s %s", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
if missing > 0 {
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
cli.Println("\n%s %s", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
}
return nil
@ -101,7 +99,7 @@ func addPkgUpdateCommand(parent *cobra.Command) {
Long: i18n.T("cmd.pkg.update.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if !updateAll && len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.specify_package"))
return cli.Err(i18n.T("cmd.pkg.error.specify_package"))
}
return runPkgUpdate(args, updateAll)
},
@ -113,66 +111,66 @@ func addPkgUpdateCommand(parent *cobra.Command) {
}
func runPkgUpdate(packages []string, all bool) error {
regPath, err := repos.FindRegistry(coreio.Local)
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := reg.BasePath
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
var toUpdate []string
if all {
for _, r := range reg.List() {
toUpdate = append(toUpdate, r.Name)
for _, repo := range registry.List() {
toUpdate = append(toUpdate, repo.Name)
}
} else {
toUpdate = packages
}
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
var updated, skipped, failed int
for _, name := range toUpdate {
repoPath := filepath.Join(basePath, name)
repoPath := core.Path(basePath, name)
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
if _, err := coreio.Local.List(core.Path(repoPath, ".git")); err != nil {
cli.Println(" %s %s (%s)", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
skipped++
continue
}
fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name)
cli.Print(" %s %s... ", dimStyle.Render("↓"), name)
cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := cmd.CombinedOutput()
proc := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := proc.CombinedOutput()
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗"))
fmt.Printf(" %s\n", strings.TrimSpace(string(output)))
cli.Println("%s", errorStyle.Render("✗"))
cli.Println(" %s", core.Trim(string(output)))
failed++
continue
}
if strings.Contains(string(output), "Already up to date") {
fmt.Printf("%s\n", dimStyle.Render(i18n.T("common.status.up_to_date")))
if core.Contains(string(output), "Already up to date") {
cli.Println("%s", dimStyle.Render(i18n.T("common.status.up_to_date")))
} else {
fmt.Printf("%s\n", successStyle.Render("✓"))
cli.Println("%s", successStyle.Render("✓"))
}
updated++
}
fmt.Println()
fmt.Printf("%s %s\n",
cli.Blank()
cli.Println("%s %s",
dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed}))
return nil
@ -193,63 +191,63 @@ func addPkgOutdatedCommand(parent *cobra.Command) {
}
func runPkgOutdated() error {
regPath, err := repos.FindRegistry(coreio.Local)
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := reg.BasePath
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
var outdated, upToDate, notInstalled int
for _, r := range reg.List() {
repoPath := filepath.Join(basePath, r.Name)
for _, repo := range registry.List() {
repoPath := core.Path(basePath, repo.Name)
if !coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
if !coreio.Local.Exists(core.Path(repoPath, ".git")) {
notInstalled++
continue
}
// Fetch updates
// Fetch updates silently.
_ = exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run()
// Check if behind
cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
output, err := cmd.Output()
// Check commit count behind upstream.
proc := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
output, err := proc.Output()
if err != nil {
continue
}
count := strings.TrimSpace(string(output))
if count != "0" {
fmt.Printf(" %s %s (%s)\n",
errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count}))
commitCount := core.Trim(string(output))
if commitCount != "0" {
cli.Println(" %s %s (%s)",
errorStyle.Render("↓"), repoNameStyle.Render(repo.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": commitCount}))
outdated++
} else {
upToDate++
}
}
fmt.Println()
cli.Blank()
if outdated == 0 {
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
} else {
fmt.Printf("%s %s\n",
cli.Println("%s %s",
dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate}))
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
cli.Println("\n%s %s", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
}
return nil

View file

@ -0,0 +1,350 @@
package pkgcmd
import (
"bytes"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"forge.lthn.ai/core/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func capturePkgOutput(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() {
os.Stdout = oldStdout
}()
fn()
require.NoError(t, w.Close())
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
return buf.String()
}
func withWorkingDir(t *testing.T, dir string) {
t.Helper()
oldwd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(dir))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldwd))
})
}
func writeTestRegistry(t *testing.T, dir string) {
t.Helper()
registry := strings.TrimSpace(`
org: host-uk
base_path: .
repos:
core-alpha:
type: foundation
description: Alpha package
core-beta:
type: module
description: Beta package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, "repos.yaml"), []byte(registry), 0644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "core-alpha", ".git"), 0755))
}
func gitCommand(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v failed: %s", args, string(out))
return string(out)
}
func commitGitRepo(t *testing.T, dir, filename, content, message string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644))
gitCommand(t, dir, "add", filename)
gitCommand(t, dir, "commit", "-m", message)
}
func setupOutdatedRegistry(t *testing.T) string {
t.Helper()
tmp := t.TempDir()
remoteDir := filepath.Join(tmp, "remote.git")
gitCommand(t, tmp, "init", "--bare", remoteDir)
seedDir := filepath.Join(tmp, "seed")
require.NoError(t, os.MkdirAll(seedDir, 0755))
gitCommand(t, seedDir, "init")
gitCommand(t, seedDir, "config", "user.email", "test@test.com")
gitCommand(t, seedDir, "config", "user.name", "Test")
commitGitRepo(t, seedDir, "repo.txt", "v1\n", "initial")
gitCommand(t, seedDir, "remote", "add", "origin", remoteDir)
gitCommand(t, seedDir, "push", "-u", "origin", "master")
freshDir := filepath.Join(tmp, "core-fresh")
gitCommand(t, tmp, "clone", remoteDir, freshDir)
staleDir := filepath.Join(tmp, "core-stale")
gitCommand(t, tmp, "clone", remoteDir, staleDir)
commitGitRepo(t, seedDir, "repo.txt", "v2\n", "second")
gitCommand(t, seedDir, "push")
gitCommand(t, freshDir, "pull", "--ff-only")
registry := strings.TrimSpace(`
org: host-uk
base_path: .
repos:
core-fresh:
type: foundation
description: Fresh package
core-stale:
type: module
description: Stale package
core-missing:
type: module
description: Missing package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
return tmp
}
func TestRunPkgList_Good(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgList("table")
require.NoError(t, err)
})
assert.Contains(t, out, "core-alpha")
assert.Contains(t, out, "core-beta")
assert.Contains(t, out, "core setup")
}
func TestRunPkgList_JSON(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgList("json")
require.NoError(t, err)
})
var report pkgListReport
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
assert.Equal(t, "json", report.Format)
assert.Equal(t, 2, report.Total)
assert.Equal(t, 1, report.Installed)
assert.Equal(t, 1, report.Missing)
require.Len(t, report.Packages, 2)
assert.Equal(t, "core-alpha", report.Packages[0].Name)
assert.True(t, report.Packages[0].Installed)
assert.Equal(t, filepath.Join(tmp, "core-alpha"), report.Packages[0].Path)
assert.Equal(t, "core-beta", report.Packages[1].Name)
assert.False(t, report.Packages[1].Installed)
}
func TestRunPkgList_UnsupportedFormat(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
err := runPkgList("yaml")
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported format")
}
func TestRunPkgOutdated_JSON(t *testing.T) {
tmp := setupOutdatedRegistry(t)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgOutdated("json")
require.NoError(t, err)
})
var report pkgOutdatedReport
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
assert.Equal(t, "json", report.Format)
assert.Equal(t, 3, report.Total)
assert.Equal(t, 2, report.Installed)
assert.Equal(t, 1, report.Missing)
assert.Equal(t, 1, report.Outdated)
assert.Equal(t, 1, report.UpToDate)
require.Len(t, report.Packages, 3)
var staleFound, freshFound, missingFound bool
for _, pkg := range report.Packages {
switch pkg.Name {
case "core-stale":
staleFound = true
assert.True(t, pkg.Installed)
assert.False(t, pkg.UpToDate)
assert.Equal(t, 1, pkg.Behind)
case "core-fresh":
freshFound = true
assert.True(t, pkg.Installed)
assert.True(t, pkg.UpToDate)
assert.Equal(t, 0, pkg.Behind)
case "core-missing":
missingFound = true
assert.False(t, pkg.Installed)
assert.False(t, pkg.UpToDate)
assert.Equal(t, 0, pkg.Behind)
}
}
assert.True(t, staleFound)
assert.True(t, freshFound)
assert.True(t, missingFound)
}
func TestRenderPkgSearchResults_ShowsMetadata(t *testing.T) {
out := capturePkgOutput(t, func() {
renderPkgSearchResults([]ghRepo{
{
FullName: "host-uk/core-alpha",
Name: "core-alpha",
Description: "Alpha package",
Visibility: "private",
StargazerCount: 42,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
},
})
})
assert.Contains(t, out, "host-uk/core-alpha")
assert.Contains(t, out, "Alpha package")
assert.Contains(t, out, "42 stars")
assert.Contains(t, out, "Go")
assert.Contains(t, out, "updated 2h ago")
}
func TestRunPkgSearch_RespectsLimitWithCachedResults(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
c, err := cache.New(nil, filepath.Join(tmp, ".core", "cache"), 0)
require.NoError(t, err)
require.NoError(t, c.Set(cache.GitHubReposKey("host-uk"), []ghRepo{
{
FullName: "host-uk/core-alpha",
Name: "core-alpha",
Description: "Alpha package",
Visibility: "public",
UpdatedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
StargazerCount: 1,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
},
{
FullName: "host-uk/core-beta",
Name: "core-beta",
Description: "Beta package",
Visibility: "public",
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
StargazerCount: 2,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
},
}))
out := capturePkgOutput(t, func() {
err := runPkgSearch("host-uk", "*", "", 1, false, "table")
require.NoError(t, err)
})
assert.Contains(t, out, "core-alpha")
assert.NotContains(t, out, "core-beta")
}
func TestRunPkgUpdate_NoArgs_UpdatesAll(t *testing.T) {
tmp := setupOutdatedRegistry(t)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgUpdate(nil, false, "table")
require.NoError(t, err)
})
assert.Contains(t, out, "updating")
assert.Contains(t, out, "core-fresh")
assert.Contains(t, out, "core-stale")
}
func TestRunPkgUpdate_JSON(t *testing.T) {
tmp := setupOutdatedRegistry(t)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgUpdate(nil, false, "json")
require.NoError(t, err)
})
var report pkgUpdateReport
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
assert.Equal(t, "json", report.Format)
assert.Equal(t, 3, report.Total)
assert.Equal(t, 2, report.Installed)
assert.Equal(t, 1, report.Missing)
assert.Equal(t, 1, report.Updated)
assert.Equal(t, 1, report.UpToDate)
assert.Equal(t, 0, report.Failed)
require.Len(t, report.Packages, 3)
var updatedFound, upToDateFound, missingFound bool
for _, pkg := range report.Packages {
switch pkg.Name {
case "core-stale":
updatedFound = true
assert.True(t, pkg.Installed)
assert.Equal(t, "updated", pkg.Status)
case "core-fresh":
upToDateFound = true
assert.True(t, pkg.Installed)
assert.Equal(t, "up_to_date", pkg.Status)
case "core-missing":
missingFound = true
assert.False(t, pkg.Installed)
assert.Equal(t, "missing", pkg.Status)
}
}
assert.True(t, updatedFound)
assert.True(t, upToDateFound)
assert.True(t, missingFound)
}

View file

@ -15,6 +15,7 @@ var (
dimStyle = cli.DimStyle
ghAuthenticated = cli.GhAuthenticated
gitClone = cli.GitClone
gitCloneRef = clonePackageAtRef
)
// AddPkgCommands adds the 'pkg' command and subcommands for package management.

View file

@ -8,12 +8,10 @@
package pkgcmd
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
@ -30,7 +28,7 @@ func addPkgRemoveCommand(parent *cobra.Command) {
changes or unpushed branches. Use --force to skip safety checks.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgRemove(args[0], removeForce)
},
@ -42,102 +40,105 @@ changes or unpushed branches. Use --force to skip safety checks.`,
}
func runPkgRemove(name string, force bool) error {
// Find package path via registry
regPath, err := repos.FindRegistry(coreio.Local)
// Find package path via registry.
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := reg.BasePath
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
repoPath := filepath.Join(basePath, name)
repoPath := core.Path(basePath, name)
if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
return fmt.Errorf("package %s is not installed at %s", name, repoPath)
if !coreio.Local.IsDir(core.Path(repoPath, ".git")) {
return cli.Err("package %s is not installed at %s", name, repoPath)
}
if !force {
blocked, reasons := checkRepoSafety(repoPath)
if blocked {
fmt.Printf("%s Cannot remove %s:\n", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
for _, r := range reasons {
fmt.Printf(" %s %s\n", errorStyle.Render("·"), r)
cli.Println("%s Cannot remove %s:", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
for _, reason := range reasons {
cli.Println(" %s %s", errorStyle.Render("·"), reason)
}
fmt.Printf("\nResolve the issues above or use --force to override.\n")
return errors.New("package has unresolved changes")
cli.Println("\nResolve the issues above or use --force to override.")
return cli.Err("package has unresolved changes")
}
}
// Remove the directory
fmt.Printf("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
// Remove the directory.
cli.Print("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
if err := coreio.Local.DeleteAll(repoPath); err != nil {
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
cli.Println("%s", errorStyle.Render("x "+err.Error()))
return err
}
fmt.Printf("%s\n", successStyle.Render("ok"))
cli.Println("%s", successStyle.Render("ok"))
return nil
}
// checkRepoSafety checks a git repo for uncommitted changes and unpushed branches.
//
// blocked, reasons := checkRepoSafety("/path/to/repo")
// if blocked { fmt.Println(reasons) }
func checkRepoSafety(repoPath string) (blocked bool, reasons []string) {
// Check for uncommitted changes (staged, unstaged, untracked)
cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain")
output, err := cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
// Check for uncommitted changes (staged, unstaged, untracked).
proc := exec.Command("git", "-C", repoPath, "status", "--porcelain")
output, err := proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d uncommitted changes", len(lines)))
reasons = append(reasons, cli.Sprintf("has %d uncommitted changes", len(lines)))
}
// Check for unpushed commits on current branch
cmd = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
output, err = cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
// Check for unpushed commits on current branch.
proc = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
output, err = proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d unpushed commits on current branch", len(lines)))
reasons = append(reasons, cli.Sprintf("has %d unpushed commits on current branch", len(lines)))
}
// Check all local branches for unpushed work
cmd = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
output, _ = cmd.Output()
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
branches := strings.Split(trimmed, "\n")
// Check all local branches for unpushed work.
proc = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
output, _ = proc.Output()
if trimmedOutput := core.Trim(string(output)); trimmedOutput != "" {
branches := core.Split(trimmedOutput, "\n")
var unmerged []string
for _, b := range branches {
b = strings.TrimSpace(b)
b = strings.TrimPrefix(b, "* ")
if b != "" {
unmerged = append(unmerged, b)
for _, branchName := range branches {
branchName = core.Trim(branchName)
branchName = core.TrimPrefix(branchName, "* ")
if branchName != "" {
unmerged = append(unmerged, branchName)
}
}
if len(unmerged) > 0 {
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d unmerged branches: %s",
len(unmerged), strings.Join(unmerged, ", ")))
reasons = append(reasons, cli.Sprintf("has %d unmerged branches: %s",
len(unmerged), core.Join(", ", unmerged...)))
}
}
// Check for stashed changes
cmd = exec.Command("git", "-C", repoPath, "stash", "list")
output, err = cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
// Check for stashed changes.
proc = exec.Command("git", "-C", repoPath, "stash", "list")
output, err = proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d stashed entries", len(lines)))
reasons = append(reasons, cli.Sprintf("has %d stashed entries", len(lines)))
}
return blocked, reasons

View file

@ -1,9 +1,11 @@
package pkgcmd
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -12,24 +14,52 @@ import (
func setupTestRepo(t *testing.T, dir, name string) string {
t.Helper()
repoPath := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(repoPath, 0755))
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
{"git", "commit", "--allow-empty", "-m", "initial"},
}
for _, c := range cmds {
cmd := exec.Command(c[0], c[1:]...)
cmd.Dir = repoPath
out, err := cmd.CombinedOutput()
require.NoError(t, err, "cmd %v failed: %s", c, string(out))
}
gitCommand(t, repoPath, "init")
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
gitCommand(t, repoPath, "config", "user.name", "Test")
gitCommand(t, repoPath, "commit", "--allow-empty", "-m", "initial")
return repoPath
}
func capturePkgStreams(t *testing.T, fn func()) (string, string) {
t.Helper()
oldStdout := os.Stdout
oldStderr := os.Stderr
rOut, wOut, err := os.Pipe()
require.NoError(t, err)
rErr, wErr, err := os.Pipe()
require.NoError(t, err)
os.Stdout = wOut
os.Stderr = wErr
defer func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
}()
fn()
require.NoError(t, wOut.Close())
require.NoError(t, wErr.Close())
var stdout bytes.Buffer
var stderr bytes.Buffer
_, err = io.Copy(&stdout, rOut)
require.NoError(t, err)
_, err = io.Copy(&stderr, rErr)
require.NoError(t, err)
return stdout.String(), stderr.String()
}
func TestCheckRepoSafety_Clean(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "clean-repo")
@ -55,38 +85,90 @@ func TestCheckRepoSafety_Stash(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "stash-repo")
// Create a file, add, stash
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644))
cmd := exec.Command("git", "add", ".")
cmd.Dir = repoPath
require.NoError(t, cmd.Run())
cmd = exec.Command("git", "stash")
cmd.Dir = repoPath
require.NoError(t, cmd.Run())
gitCommand(t, repoPath, "add", ".")
gitCommand(t, repoPath, "stash")
blocked, reasons := checkRepoSafety(repoPath)
assert.True(t, blocked)
found := false
for _, r := range reasons {
if assert.ObjectsAreEqual("stashed", "") || len(r) > 0 {
if contains(r, "stash") {
found = true
}
if strings.Contains(r, "stash") {
found = true
}
}
assert.True(t, found, "expected stash warning in reasons: %v", reasons)
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
func TestRunPkgRemove_RemovesRegistryEntry_Good(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "core-alpha")
registry := strings.TrimSpace(`
version: 1
org: host-uk
base_path: .
repos:
core-alpha:
type: foundation
description: Alpha package
core-beta:
type: module
description: Beta package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
oldwd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(tmp))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldwd))
})
require.NoError(t, runPkgRemove("core-alpha", false))
_, err = os.Stat(repoPath)
assert.True(t, os.IsNotExist(err))
updated, err := os.ReadFile(filepath.Join(tmp, "repos.yaml"))
require.NoError(t, err)
assert.NotContains(t, string(updated), "core-alpha")
assert.Contains(t, string(updated), "core-beta")
}
func containsStr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
func TestRunPkgRemove_Bad_BlockedWarningsGoToStderr(t *testing.T) {
tmp := t.TempDir()
registry := strings.TrimSpace(`
org: host-uk
base_path: .
repos:
core-alpha:
type: foundation
description: Alpha package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
repoPath := filepath.Join(tmp, "core-alpha")
require.NoError(t, os.MkdirAll(repoPath, 0755))
gitCommand(t, repoPath, "init")
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
gitCommand(t, repoPath, "config", "user.name", "Test")
commitGitRepo(t, repoPath, "file.txt", "v1\n", "initial")
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "file.txt"), []byte("v2\n"), 0644))
withWorkingDir(t, tmp)
stdout, stderr := capturePkgStreams(t, func() {
err := runPkgRemove("core-alpha", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "unresolved changes")
})
assert.Empty(t, stdout)
assert.Contains(t, stderr, "Cannot remove core-alpha")
assert.Contains(t, stderr, "uncommitted changes")
assert.Contains(t, stderr, "Resolve the issues above or use --force to override.")
}

View file

@ -2,16 +2,12 @@ package pkgcmd
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-cache"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
@ -69,82 +65,83 @@ type ghRepo struct {
}
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
// Initialize cache in workspace .core/ directory
var cacheDir string
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
// Initialise cache in workspace .core/ directory.
var cacheDirectory string
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
cacheDirectory = core.Path(core.PathDir(registryPath), ".core", "cache")
}
c, err := cache.New(coreio.Local, cacheDir, 0)
cacheInstance, err := cache.New(coreio.Local, cacheDirectory, 0)
if err != nil {
c = nil
cacheInstance = nil
}
cacheKey := cache.GitHubReposKey(org)
var ghRepos []ghRepo
var fromCache bool
// Try cache first (unless refresh requested)
if c != nil && !refresh {
if found, err := c.Get(cacheKey, &ghRepos); found && err == nil {
// Try cache first (unless refresh requested).
if cacheInstance != nil && !refresh {
if found, err := cacheInstance.Get(cacheKey, &ghRepos); found && err == nil {
fromCache = true
age := c.Age(cacheKey)
fmt.Printf("%s %s %s\n", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
age := cacheInstance.Age(cacheKey)
cli.Println("%s %s %s", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(cli.Sprintf("(%s ago)", age.Round(time.Second))))
}
}
// Fetch from GitHub if not cached
// Fetch from GitHub if not cached.
if !fromCache {
if !ghAuthenticated() {
return errors.New(i18n.T("cmd.pkg.error.gh_not_authenticated"))
return cli.Err(i18n.T("cmd.pkg.error.gh_not_authenticated"))
}
if os.Getenv("GH_TOKEN") != "" {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
fmt.Printf("%s %s\n\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
if core.Env("GH_TOKEN") != "" {
cli.Println("%s %s", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
cli.Println("%s %s\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
}
fmt.Printf("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
cli.Print("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
cmd := exec.Command("gh", "repo", "list", org,
proc := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", fmt.Sprintf("%d", limit))
output, err := cmd.CombinedOutput()
"--limit", cli.Sprintf("%d", limit))
output, err := proc.CombinedOutput()
if err != nil {
fmt.Println()
errStr := strings.TrimSpace(string(output))
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
return errors.New(i18n.T("cmd.pkg.error.auth_failed"))
cli.Blank()
errorOutput := core.Trim(string(output))
if core.Contains(errorOutput, "401") || core.Contains(errorOutput, "Bad credentials") {
return cli.Err(i18n.T("cmd.pkg.error.auth_failed"))
}
return fmt.Errorf("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errStr)
return cli.Err("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errorOutput)
}
if err := json.Unmarshal(output, &ghRepos); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.parse", "results"), err)
result := core.JSONUnmarshal(output, &ghRepos)
if !result.OK {
return cli.Wrap(result.Value.(error), i18n.T("i18n.fail.parse", "results"))
}
if c != nil {
_ = c.Set(cacheKey, ghRepos)
if cacheInstance != nil {
_ = cacheInstance.Set(cacheKey, ghRepos)
}
fmt.Printf("%s\n", successStyle.Render("✓"))
cli.Println("%s", successStyle.Render("✓"))
}
// Filter by glob pattern and type
// Filter by glob pattern and type.
var filtered []ghRepo
for _, r := range ghRepos {
if !matchGlob(pattern, r.Name) {
for _, repo := range ghRepos {
if !matchGlob(pattern, repo.Name) {
continue
}
if repoType != "" && !strings.Contains(r.Name, repoType) {
if repoType != "" && !core.Contains(repo.Name, repoType) {
continue
}
filtered = append(filtered, r)
filtered = append(filtered, repo)
}
if len(filtered) == 0 {
fmt.Println(i18n.T("cmd.pkg.search.no_repos_found"))
cli.Println("%s", i18n.T("cmd.pkg.search.no_repos_found"))
return nil
}
@ -152,54 +149,65 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error
return cmp.Compare(a.Name, b.Name)
})
fmt.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
cli.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
for _, r := range filtered {
for _, repo := range filtered {
visibility := ""
if r.Visibility == "private" {
if repo.Visibility == "private" {
visibility = dimStyle.Render(" " + i18n.T("cmd.pkg.search.private_label"))
}
desc := r.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
description := repo.Description
if len(description) > 50 {
description = description[:47] + "..."
}
if desc == "" {
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
if description == "" {
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility)
fmt.Printf(" %s\n", desc)
cli.Println(" %s%s", repoNameStyle.Render(repo.Name), visibility)
cli.Println(" %s", description)
}
fmt.Println()
fmt.Printf("%s %s\n", i18n.T("common.hint.install_with"), dimStyle.Render(fmt.Sprintf("core pkg install %s/<repo-name>", org)))
cli.Blank()
cli.Println("%s %s", i18n.T("common.hint.install_with"), dimStyle.Render(cli.Sprintf("core pkg install %s/<repo-name>", org)))
return nil
}
// matchGlob does simple glob matching with * wildcards
// matchGlob does simple glob matching with * wildcards.
//
// matchGlob("core-*", "core-php") // true
// matchGlob("*-mod", "core-php") // false
func matchGlob(pattern, name string) bool {
if pattern == "*" || pattern == "" {
return true
}
parts := strings.Split(pattern, "*")
parts := core.Split(pattern, "*")
pos := 0
for i, part := range parts {
if part == "" {
continue
}
idx := strings.Index(name[pos:], part)
// Find part in name starting from pos.
remaining := name[pos:]
idx := -1
for j := 0; j <= len(remaining)-len(part); j++ {
if remaining[j:j+len(part)] == part {
idx = j
break
}
}
if idx == -1 {
return false
}
if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 {
if i == 0 && !core.HasPrefix(pattern, "*") && idx != 0 {
return false
}
pos += idx + len(part)
}
if !strings.HasSuffix(pattern, "*") && pos != len(name) {
if !core.HasSuffix(pattern, "*") && pos != len(name) {
return false
}
return true

View file

@ -0,0 +1,66 @@
package pkgcmd
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolvePkgSearchPattern_Good(t *testing.T) {
t.Run("uses flag pattern when set", func(t *testing.T) {
got := resolvePkgSearchPattern("core-*", []string{"api"})
assert.Equal(t, "core-*", got)
})
t.Run("uses positional pattern when flag is empty", func(t *testing.T) {
got := resolvePkgSearchPattern("", []string{"api"})
assert.Equal(t, "api", got)
})
t.Run("defaults to wildcard when nothing is provided", func(t *testing.T) {
got := resolvePkgSearchPattern("", nil)
assert.Equal(t, "*", got)
})
}
func TestBuildPkgSearchReport_Good(t *testing.T) {
repos := []ghRepo{
{
FullName: "host-uk/core-api",
Name: "core-api",
Description: "REST API framework",
Visibility: "public",
UpdatedAt: "2026-03-30T12:00:00Z",
StargazerCount: 42,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
},
}
report := buildPkgSearchReport("host-uk", "core-*", "api", 50, true, repos)
assert.Equal(t, "json", report.Format)
assert.Equal(t, "host-uk", report.Org)
assert.Equal(t, "core-*", report.Pattern)
assert.Equal(t, "api", report.Type)
assert.Equal(t, 50, report.Limit)
assert.True(t, report.Cached)
assert.Equal(t, 1, report.Count)
requireRepo := report.Repos
if assert.Len(t, requireRepo, 1) {
assert.Equal(t, "core-api", requireRepo[0].Name)
assert.Equal(t, "host-uk/core-api", requireRepo[0].FullName)
assert.Equal(t, "REST API framework", requireRepo[0].Description)
assert.Equal(t, "public", requireRepo[0].Visibility)
assert.Equal(t, 42, requireRepo[0].StargazerCount)
assert.Equal(t, "Go", requireRepo[0].PrimaryLanguage)
assert.Equal(t, "2026-03-30T12:00:00Z", requireRepo[0].UpdatedAt)
assert.NotEmpty(t, requireRepo[0].Updated)
}
out, err := json.Marshal(report)
assert.NoError(t, err)
assert.Contains(t, string(out), `"format":"json"`)
}

View file

@ -33,4 +33,5 @@ core pkg update core-api
```bash
core pkg outdated
core pkg outdated --format json
```

View file

@ -60,10 +60,10 @@ core pkg search --refresh
## pkg install
Clone a package from GitHub.
Clone a package from GitHub. If you pass only a repo name, `core` assumes the `host-uk` org.
```bash
core pkg install <org/repo> [flags]
core pkg install [org/]repo [flags]
```
### Flags
@ -76,6 +76,9 @@ core pkg install <org/repo> [flags]
### Examples
```bash
# Clone from the default host-uk org
core pkg install core-api
# Clone to packages/
core pkg install host-uk/core-php
@ -98,6 +101,16 @@ core pkg list
Shows installed status (✓) and description for each package.
### Flags
| Flag | Description |
|------|-------------|
| `--format` | Output format (`table` or `json`) |
### JSON Output
When `--format json` is set, `core pkg list` emits a structured report with package entries, installed state, and summary counts.
---
## pkg update
@ -113,6 +126,7 @@ core pkg update [<name>...] [flags]
| Flag | Description |
|------|-------------|
| `--all` | Update all packages |
| `--format` | Output format (`table` or `json`) |
### Examples
@ -122,8 +136,15 @@ core pkg update core-php
# Update all packages
core pkg update --all
# JSON output for automation
core pkg update --format json
```
### JSON Output
When `--format json` is set, `core pkg update` emits a structured report with per-package update status and summary totals.
---
## pkg outdated
@ -136,6 +157,16 @@ core pkg outdated
Fetches from remote and shows packages that are behind.
### Flags
| Flag | Description |
|------|-------------|
| `--format` | Output format (`table` or `json`) |
### JSON Output
When `--format json` is set, `core pkg outdated` emits a structured report with package status, behind counts, and summary totals.
---
## See Also

View file

@ -19,6 +19,7 @@ core pkg search [flags]
| `--type` | Filter by type in name (mod, services, plug, website) |
| `--limit` | Max results (default: 50) |
| `--refresh` | Bypass cache and fetch fresh data |
| `--format` | Output format (`table` or `json`) |
## Examples
@ -40,6 +41,9 @@ core pkg search --refresh
# Combine filters
core pkg search --pattern "core-*" --type mod --limit 20
# JSON output for automation
core pkg search --format json
```
## Output

View file

@ -85,6 +85,11 @@ Persistent flags are inherited by all subcommands:
```go
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode")
cli.PersistentIntFlag(parentCmd, &retries, "retries", "r", 3, "Retry count")
cli.PersistentInt64Flag(parentCmd, &seed, "seed", "", 0, "Seed value")
cli.PersistentFloat64Flag(parentCmd, &ratio, "ratio", "", 1.0, "Scaling ratio")
cli.PersistentDurationFlag(parentCmd, &timeout, "timeout", "t", 30*time.Second, "Timeout")
cli.PersistentStringSliceFlag(parentCmd, &tags, "tag", "", nil, "Tags")
```
## Args Validation

View file

@ -5,7 +5,7 @@ description: Daemon process management, PID files, health checks, and execution
# Daemon Mode
The framework provides both low-level daemon primitives and a high-level command group that adds `start`, `stop`, `status`, and `run` subcommands to your CLI.
The framework provides execution mode detection and signal handling for daemon processes.
## Execution Modes
@ -29,63 +29,9 @@ cli.IsStdinTTY() // stdin is a terminal?
cli.IsStderrTTY() // stderr is a terminal?
```
## Adding Daemon Commands
## Simple Daemon
`AddDaemonCommand` registers a command group with four subcommands:
```go
func AddMyCommands(root *cli.Command) {
cli.AddDaemonCommand(root, cli.DaemonCommandConfig{
Name: "daemon", // Command group name (default: "daemon")
Description: "Manage the worker", // Short description
PIDFile: "/var/run/myapp.pid",
HealthAddr: ":9090",
RunForeground: func(ctx context.Context, daemon *process.Daemon) error {
// Your long-running service logic here.
// ctx is cancelled on SIGINT/SIGTERM.
return runWorker(ctx)
},
})
}
```
This creates:
- `myapp daemon start` -- Re-executes the binary as a background process with `CORE_DAEMON=1`
- `myapp daemon stop` -- Sends SIGTERM to the daemon, waits for shutdown (30s timeout, then SIGKILL)
- `myapp daemon status` -- Reports whether the daemon is running and queries health endpoints
- `myapp daemon run` -- Runs in the foreground (for development or process managers like systemd)
### Custom Persistent Flags
Add flags that apply to all daemon subcommands:
```go
cli.AddDaemonCommand(root, cli.DaemonCommandConfig{
// ...
Flags: func(cmd *cli.Command) {
cli.PersistentStringFlag(cmd, &configPath, "config", "c", "", "Config file")
},
ExtraStartArgs: func() []string {
return []string{"--config", configPath}
},
})
```
`ExtraStartArgs` passes additional flags when re-executing the binary as a daemon.
### Health Endpoints
When `HealthAddr` is set, the daemon serves:
- `GET /health` -- Liveness check (200 if server is up, 503 if health checks fail)
- `GET /ready` -- Readiness check (200 if `daemon.SetReady(true)` has been called)
The `start` command waits up to 5 seconds for the health endpoint to become available before reporting success.
## Simple Daemon (Manual)
For cases where you do not need the full command group:
Use `cli.Context()` for cancellation-aware daemon loops:
```go
func runDaemon(cmd *cli.Command, args []string) error {
@ -96,6 +42,39 @@ func runDaemon(cmd *cli.Command, args []string) error {
}
```
## Daemon Helper
Use `cli.NewDaemon()` when you want a helper that writes a PID file and serves
basic `/health` and `/ready` probes:
```go
daemon := cli.NewDaemon(cli.DaemonOptions{
PIDFile: "/tmp/core.pid",
HealthAddr: "127.0.0.1:8080",
HealthCheck: func() bool {
return true
},
ReadyCheck: func() bool {
return true
},
})
if err := daemon.Start(context.Background()); err != nil {
return err
}
defer func() {
_ = daemon.Stop(context.Background())
}()
```
`Start()` writes the current process ID to the configured file, and `Stop()`
removes it after shutting the probe server down.
If you need to stop a daemon process from outside its own process tree, use
`cli.StopPIDFile(pidFile, timeout)`. It sends `SIGTERM`, waits up to the
timeout for exit, escalates to `SIGKILL` if needed, and removes the PID file
after the process stops.
## Shutdown with Timeout
The daemon stop logic sends SIGTERM and waits up to 30 seconds. If the process has not exited by then, it sends SIGKILL and removes the PID file.
@ -117,15 +96,3 @@ cli.Init(cli.Options{
```
No manual signal handling is needed in commands. Use `cli.Context()` for cancellation-aware operations.
## DaemonCommandConfig Reference
| Field | Type | Description |
|-------|------|-------------|
| `Name` | `string` | Command group name (default: `"daemon"`) |
| `Description` | `string` | Short description for help text |
| `PIDFile` | `string` | PID file path (default flag value) |
| `HealthAddr` | `string` | Health check listen address (default flag value) |
| `RunForeground` | `func(ctx, daemon) error` | Service logic for foreground/daemon mode |
| `Flags` | `func(cmd)` | Registers custom persistent flags |
| `ExtraStartArgs` | `func() []string` | Additional args for background re-exec |

View file

@ -57,10 +57,10 @@ If a command returns an `*ExitError`, the process exits with that code. All othe
This is the preferred way to register commands. It wraps your registration function in a Core service that participates in the lifecycle:
```go
func WithCommands(name string, register func(root *Command)) core.Option
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup
```
During startup, the Core framework calls your function with the root cobra command. Your function adds subcommands to it:
During `Main()`, the CLI calls your function with the Core instance. Internally it retrieves the root cobra command and passes it to your register function:
```go
func AddScoreCommands(root *cli.Command) {
@ -98,18 +98,17 @@ func main() {
}
```
Where `Commands()` returns a slice of framework options:
Where `Commands()` returns a slice of `CommandSetup` functions:
```go
package lemcmd
import (
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/cli/pkg/cli"
)
func Commands() []core.Option {
return []core.Option{
func Commands() []cli.CommandSetup {
return []cli.CommandSetup{
cli.WithCommands("score", addScoreCommands),
cli.WithCommands("gen", addGenCommands),
cli.WithCommands("data", addDataCommands),
@ -141,7 +140,7 @@ If you need more control over the lifecycle:
cli.Init(cli.Options{
AppName: "myapp",
Version: "1.0.0",
Services: []core.Option{...},
Services: []core.Service{...},
OnReload: func() error { return reloadConfig() },
})
defer cli.Shutdown()

View file

@ -52,6 +52,7 @@ The framework has three layers:
| `TreeNode` | Tree structure with box-drawing connectors |
| `TaskTracker` | Concurrent task display with live spinners |
| `CheckBuilder` | Fluent API for pass/fail/skip result lines |
| `Daemon` | PID file and probe helper for background processes |
| `AnsiStyle` | Terminal text styling (bold, dim, colour) |
## Built-in Services

View file

@ -280,4 +280,5 @@ cli.LogInfo("server started", "port", 8080)
cli.LogWarn("slow query", "duration", "3.2s")
cli.LogError("connection failed", "err", err)
cli.LogSecurity("login attempt", "user", "admin")
cli.LogSecurityf("login attempt from %s", username)
```

View file

@ -135,6 +135,12 @@ choice := cli.Choose("Select a file:", files,
)
```
Enable `cli.Filter()` to let users type a substring and narrow the visible choices before selecting a number:
```go
choice := cli.Choose("Select:", items, cli.Filter[Item]())
```
With a default selection:
```go

View file

@ -34,17 +34,19 @@ When word-wrap is enabled, the stream tracks the current column position and ins
## Custom Output Writer
By default, streams write to `os.Stdout`. Redirect to any `io.Writer`:
By default, streams write to the CLI stdout writer (`stdoutWriter()`), so tests can
redirect output via `cli.SetStdout` and other callers can provide any `io.Writer`:
```go
var buf strings.Builder
stream := cli.NewStream(cli.WithStreamOutput(&buf))
// ... write tokens ...
stream.Done()
result := stream.Captured() // or buf.String()
result, ok := stream.CapturedOK() // or buf.String()
```
`Captured()` returns the output as a string when using a `*strings.Builder` or any `fmt.Stringer`.
`CapturedOK()` reports whether capture is supported by the configured writer.
## Reading from `io.Reader`
@ -68,14 +70,15 @@ stream.Done()
| `Done()` | Signal completion (adds trailing newline if needed) |
| `Wait()` | Block until `Done` is called |
| `Column()` | Current column position |
| `Captured()` | Get output as string (requires `*strings.Builder` or `fmt.Stringer` writer) |
| `Captured()` | Get output as string (returns `""` if capture is unsupported) |
| `CapturedOK()` | Get output and support status |
## Options
| Option | Description |
|--------|-------------|
| `WithWordWrap(cols)` | Set the word-wrap column width |
| `WithStreamOutput(w)` | Set the output writer (default: `os.Stdout`) |
| `WithStreamOutput(w)` | Set the output writer (default: `stdoutWriter()`) |
## Example: LLM Token Streaming

15
go.mod
View file

@ -1,24 +1,26 @@
module forge.lthn.ai/core/cli
module dappco.re/go/core/cli
go 1.26.0
require forge.lthn.ai/core/go v0.3.2
require dappco.re/go/core v0.4.7
require (
forge.lthn.ai/core/go-i18n v0.1.7
forge.lthn.ai/core/go-log v0.0.4
dappco.re/go/core/i18n v0.1.7
dappco.re/go/core/log v0.0.4
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.6
github.com/mattn/go-runewidth v0.0.21
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.41.0
)
require (
forge.lthn.ai/core/go-inference v0.1.6 // indirect
dappco.re/go/core v0.3.3 // indirect
dappco.re/go/core/inference v0.1.7 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
@ -29,7 +31,6 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect

10
go.sum
View file

@ -1,9 +1,11 @@
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=

View file

@ -18,8 +18,9 @@ const (
)
var (
colorEnabled = true
colorEnabledMu sync.RWMutex
colorEnabled = true
colorEnabledMu sync.RWMutex
asciiDisabledColors bool
)
func init() {
@ -48,6 +49,18 @@ func ColorEnabled() bool {
func SetColorEnabled(enabled bool) {
colorEnabledMu.Lock()
colorEnabled = enabled
if enabled {
asciiDisabledColors = false
}
colorEnabledMu.Unlock()
}
func restoreColorIfASCII() {
colorEnabledMu.Lock()
if asciiDisabledColors {
colorEnabled = true
asciiDisabledColors = false
}
colorEnabledMu.Unlock()
}

View file

@ -76,9 +76,7 @@ func TestRender_ColorEnabled_Good(t *testing.T) {
}
func TestUseASCII_Good(t *testing.T) {
// Save original state
original := ColorEnabled()
defer SetColorEnabled(original)
restoreThemeAndColors(t)
// Enable first, then UseASCII should disable colors
SetColorEnabled(true)
@ -88,10 +86,76 @@ func TestUseASCII_Good(t *testing.T) {
}
}
func TestUseUnicodeAndEmojiRestoreColorsAfterASCII(t *testing.T) {
restoreThemeAndColors(t)
SetColorEnabled(true)
UseASCII()
if ColorEnabled() {
t.Fatal("UseASCII should disable colors")
}
UseUnicode()
if !ColorEnabled() {
t.Fatal("UseUnicode should restore colors after ASCII mode")
}
UseASCII()
if ColorEnabled() {
t.Fatal("UseASCII should disable colors again")
}
UseEmoji()
if !ColorEnabled() {
t.Fatal("UseEmoji should restore colors after ASCII mode")
}
}
func TestRender_NilStyle_Good(t *testing.T) {
restoreThemeAndColors(t)
var s *AnsiStyle
got := s.Render("test")
if got != "test" {
t.Errorf("Nil style should return plain text, got %q", got)
}
}
func TestAnsiStyle_Bad(t *testing.T) {
restoreThemeAndColors(t)
original := ColorEnabled()
defer SetColorEnabled(original)
// Invalid hex colour falls back to white (255,255,255).
SetColorEnabled(true)
style := NewStyle().Foreground("notahex")
got := style.Render("text")
if !strings.Contains(got, "text") {
t.Errorf("Invalid hex: expected 'text' in output, got %q", got)
}
// Short hex (less than 6 chars) also falls back.
style = NewStyle().Foreground("#abc")
got = style.Render("x")
if !strings.Contains(got, "x") {
t.Errorf("Short hex: expected 'x' in output, got %q", got)
}
}
func TestAnsiStyle_Ugly(t *testing.T) {
restoreThemeAndColors(t)
original := ColorEnabled()
defer SetColorEnabled(original)
// All style modifiers stack without panicking.
SetColorEnabled(true)
style := NewStyle().Bold().Dim().Italic().Underline().
Foreground("#3b82f6").Background("#1f2937")
got := style.Render("styled")
if !strings.Contains(got, "styled") {
t.Errorf("All modifiers: expected 'styled' in output, got %q", got)
}
// Empty string renders without panicking.
got = style.Render("")
_ = got
}

View file

@ -7,9 +7,9 @@ import (
"os"
"runtime/debug"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"github.com/spf13/cobra"
)
@ -34,9 +34,16 @@ var (
)
// SemVer returns the full SemVer 2.0.0 version string.
// - Release: 1.2.0
// - Pre-release: 1.2.0-dev.8
// - Full: 1.2.0-dev.8+df94c24.20260206
//
// Examples:
// // Release only:
// // AppVersion=1.2.0 -> 1.2.0
// cli.AppVersion = "1.2.0"
// fmt.Println(cli.SemVer())
//
// // Pre-release + commit + date:
// // AppVersion=1.2.0, BuildPreRelease=dev.8, BuildCommit=df94c24, BuildDate=20260206
// // -> 1.2.0-dev.8+df94c24.20260206
func SemVer() string {
v := AppVersion
if BuildPreRelease != "" {
@ -60,31 +67,42 @@ func WithAppName(name string) {
AppName = name
}
// Main initialises and runs the CLI application.
// Pass command services via WithCommands to register CLI commands
// through the Core framework lifecycle.
//
// cli.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
//
// Exits with code 1 on error or panic.
// LocaleSource pairs a filesystem with a directory for loading translations.
type LocaleSource = i18n.FSSource
// WithLocales returns a locale source for use with MainWithLocales.
//
// Example:
// fs := embed.FS{}
// locales := cli.WithLocales(fs, "locales")
// cli.MainWithLocales([]cli.LocaleSource{locales})
func WithLocales(fsys fs.FS, dir string) LocaleSource {
return LocaleSource{FS: fsys, Dir: dir}
}
// CommandSetup is a function that registers commands on the CLI after init.
//
// Example:
// cli.Main(
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
type CommandSetup func(c *core.Core)
// Main initialises and runs the CLI with the framework's built-in translations.
func Main(commands ...core.Option) {
//
// Example:
// cli.WithAppName("core")
// cli.Main(config.AddConfigCommands)
func Main(commands ...CommandSetup) {
MainWithLocales(nil, commands...)
}
// MainWithLocales initialises and runs the CLI with additional translation sources.
func MainWithLocales(locales []LocaleSource, commands ...core.Option) {
//
// Example:
// locales := []cli.LocaleSource{cli.WithLocales(embeddedLocales, "locales")}
// cli.MainWithLocales(locales, doctor.AddDoctorCommands)
func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
// Recovery from panics
defer func() {
if r := recover(); r != nil {
@ -103,25 +121,22 @@ func MainWithLocales(locales []LocaleSource, commands ...core.Option) {
extraFS = append(extraFS, i18n.FSSource{FS: lfs, Dir: "."})
}
// Core services load first, then command services
services := []core.Option{
core.WithName("i18n", i18n.NewCoreService(i18n.ServiceOptions{
ExtraFS: extraFS,
})),
}
services = append(services, commands...)
// Initialise CLI runtime with services
// Initialise CLI runtime
if err := Init(Options{
AppName: AppName,
Version: SemVer(),
Services: services,
AppName: AppName,
Version: SemVer(),
I18nSources: extraFS,
}); err != nil {
Error(err.Error())
os.Exit(1)
}
defer Shutdown()
// Run command setup functions
for _, setup := range commands {
setup(Core())
}
// Add completion command to the CLI's root
RootCmd().AddCommand(newCompletionCmd())
@ -185,13 +200,13 @@ PowerShell:
Run: func(cmd *cobra.Command, args []string) {
switch args[0] {
case "bash":
_ = cmd.Root().GenBashCompletion(os.Stdout)
_ = cmd.Root().GenBashCompletion(stdoutWriter())
case "zsh":
_ = cmd.Root().GenZshCompletion(os.Stdout)
_ = cmd.Root().GenZshCompletion(stdoutWriter())
case "fish":
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
_ = cmd.Root().GenFishCompletion(stdoutWriter(), true)
case "powershell":
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
_ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter())
}
},
}

View file

@ -1,7 +1,5 @@
package cli
import "fmt"
// CheckBuilder provides fluent API for check results.
type CheckBuilder struct {
name string
@ -40,7 +38,7 @@ func (c *CheckBuilder) Fail() *CheckBuilder {
func (c *CheckBuilder) Skip() *CheckBuilder {
c.status = "skipped"
c.style = DimStyle
c.icon = "-"
c.icon = Glyph(":skip:")
return c
}
@ -66,26 +64,27 @@ func (c *CheckBuilder) Message(msg string) *CheckBuilder {
// String returns the formatted check line.
func (c *CheckBuilder) String() string {
icon := c.icon
icon := compileGlyphs(c.icon)
if c.style != nil {
icon = c.style.Render(c.icon)
icon = c.style.Render(icon)
}
status := c.status
name := Pad(compileGlyphs(c.name), 20)
status := Pad(compileGlyphs(c.status), 10)
if c.style != nil && c.status != "" {
status = c.style.Render(c.status)
status = c.style.Render(status)
}
if c.duration != "" {
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
return Sprintf(" %s %s %s %s", icon, name, status, DimStyle.Render(compileGlyphs(c.duration)))
}
if status != "" {
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
return Sprintf(" %s %s %s", icon, name, status)
}
return fmt.Sprintf(" %s %s", icon, c.name)
return Sprintf(" %s %s", icon, name)
}
// Print outputs the check result.
func (c *CheckBuilder) Print() {
fmt.Println(c.String())
Println("%s", c.String())
}

View file

@ -1,49 +1,62 @@
package cli
import "testing"
import (
"strings"
"testing"
)
func TestCheckBuilder(t *testing.T) {
func TestCheckBuilder_Good(t *testing.T) {
restoreThemeAndColors(t)
UseASCII() // Deterministic output
// Pass
c := Check("foo").Pass()
got := c.String()
checkResult := Check("database").Pass()
got := checkResult.String()
if got == "" {
t.Error("Empty output for Pass")
t.Error("Pass: expected non-empty output")
}
// Fail
c = Check("foo").Fail()
got = c.String()
if got == "" {
t.Error("Empty output for Fail")
}
// Skip
c = Check("foo").Skip()
got = c.String()
if got == "" {
t.Error("Empty output for Skip")
}
// Warn
c = Check("foo").Warn()
got = c.String()
if got == "" {
t.Error("Empty output for Warn")
}
// Duration
c = Check("foo").Pass().Duration("1s")
got = c.String()
if got == "" {
t.Error("Empty output for Duration")
}
// Message
c = Check("foo").Message("status")
got = c.String()
if got == "" {
t.Error("Empty output for Message")
if !strings.Contains(got, "database") {
t.Errorf("Pass: expected name in output, got %q", got)
}
}
func TestCheckBuilder_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
checkResult := Check("lint").Fail()
got := checkResult.String()
if got == "" {
t.Error("Fail: expected non-empty output")
}
checkResult = Check("build").Skip()
got = checkResult.String()
if got == "" {
t.Error("Skip: expected non-empty output")
}
checkResult = Check("tests").Warn()
got = checkResult.String()
if got == "" {
t.Error("Warn: expected non-empty output")
}
}
func TestCheckBuilder_Ugly(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
// Zero-value builder should not panic.
checkResult := &CheckBuilder{}
got := checkResult.String()
if got == "" {
t.Error("Ugly: empty builder should still produce output")
}
// Duration and Message chaining.
checkResult = Check("audit").Pass().Duration("2.3s").Message("all clear")
got = checkResult.String()
if !strings.Contains(got, "2.3s") {
t.Errorf("Ugly: expected duration in output, got %q", got)
}
}

View file

@ -173,6 +173,32 @@ func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []stri
}
}
// StringArrayFlag adds a string array flag to a command.
// The value will be stored in the provided pointer.
//
// var tags []string
// cli.StringArrayFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
func StringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.Flags().StringArrayVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringArrayVar(ptr, name, def, usage)
}
}
// StringToStringFlag adds a string-to-string map flag to a command.
// The value will be stored in the provided pointer.
//
// var labels map[string]string
// cli.StringToStringFlag(cmd, &labels, "label", "l", nil, "Labels to apply")
func StringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
if short != "" {
cmd.Flags().StringToStringVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringToStringVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Persistent Flag Helpers
// ─────────────────────────────────────────────────────────────────────────────
@ -195,6 +221,69 @@ func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, u
}
}
// PersistentIntFlag adds a persistent integer flag (inherited by subcommands).
func PersistentIntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
if short != "" {
cmd.PersistentFlags().IntVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().IntVar(ptr, name, def, usage)
}
}
// PersistentInt64Flag adds a persistent int64 flag (inherited by subcommands).
func PersistentInt64Flag(cmd *Command, ptr *int64, name, short string, def int64, usage string) {
if short != "" {
cmd.PersistentFlags().Int64VarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().Int64Var(ptr, name, def, usage)
}
}
// PersistentFloat64Flag adds a persistent float64 flag (inherited by subcommands).
func PersistentFloat64Flag(cmd *Command, ptr *float64, name, short string, def float64, usage string) {
if short != "" {
cmd.PersistentFlags().Float64VarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().Float64Var(ptr, name, def, usage)
}
}
// PersistentDurationFlag adds a persistent time.Duration flag (inherited by subcommands).
func PersistentDurationFlag(cmd *Command, ptr *time.Duration, name, short string, def time.Duration, usage string) {
if short != "" {
cmd.PersistentFlags().DurationVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().DurationVar(ptr, name, def, usage)
}
}
// PersistentStringSliceFlag adds a persistent string slice flag (inherited by subcommands).
func PersistentStringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.PersistentFlags().StringSliceVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringSliceVar(ptr, name, def, usage)
}
}
// PersistentStringArrayFlag adds a persistent string array flag (inherited by subcommands).
func PersistentStringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.PersistentFlags().StringArrayVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringArrayVar(ptr, name, def, usage)
}
}
// PersistentStringToStringFlag adds a persistent string-to-string map flag (inherited by subcommands).
func PersistentStringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
if short != "" {
cmd.PersistentFlags().StringToStringVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringToStringVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Command Configuration
// ─────────────────────────────────────────────────────────────────────────────

73
pkg/cli/command_test.go Normal file
View file

@ -0,0 +1,73 @@
package cli
import "testing"
func TestCommand_Good(t *testing.T) {
// NewCommand creates a command with RunE.
called := false
cmd := NewCommand("build", "Build the project", "", func(cmd *Command, args []string) error {
called = true
return nil
})
if cmd == nil {
t.Fatal("NewCommand: returned nil")
}
if cmd.Use != "build" {
t.Errorf("NewCommand: Use=%q, expected 'build'", cmd.Use)
}
if cmd.RunE == nil {
t.Fatal("NewCommand: RunE is nil")
}
_ = called
// NewGroup creates a command with no RunE.
groupCmd := NewGroup("dev", "Development commands", "")
if groupCmd.RunE != nil {
t.Error("NewGroup: RunE should be nil")
}
// NewRun creates a command with Run.
runCmd := NewRun("version", "Show version", "", func(cmd *Command, args []string) {})
if runCmd.Run == nil {
t.Fatal("NewRun: Run is nil")
}
}
func TestCommand_Bad(t *testing.T) {
// NewCommand with empty long string should not set Long.
cmd := NewCommand("test", "Short desc", "", func(cmd *Command, args []string) error {
return nil
})
if cmd.Long != "" {
t.Errorf("NewCommand: Long should be empty, got %q", cmd.Long)
}
// Flag helpers with empty short should not add short flag.
var value string
StringFlag(cmd, &value, "output", "", "default", "Output path")
if cmd.Flags().Lookup("output") == nil {
t.Error("StringFlag: flag 'output' not registered")
}
}
func TestCommand_Ugly(t *testing.T) {
// WithArgs and WithExample are chainable.
cmd := NewCommand("deploy", "Deploy", "Long desc", func(cmd *Command, args []string) error {
return nil
})
result := WithExample(cmd, "core deploy production")
if result != cmd {
t.Error("WithExample: should return the same command")
}
if cmd.Example != "core deploy production" {
t.Errorf("WithExample: Example=%q", cmd.Example)
}
// ExactArgs, NoArgs, MinimumNArgs, MaximumNArgs, ArbitraryArgs should not panic.
_ = ExactArgs(1)
_ = NoArgs()
_ = MinimumNArgs(1)
_ = MaximumNArgs(5)
_ = ArbitraryArgs()
_ = RangeArgs(1, 3)
}

View file

@ -2,80 +2,40 @@
package cli
import (
"context"
"io/fs"
"iter"
"sync"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
)
// WithCommands creates a framework Option that registers a command group.
// The register function receives the root command during service startup,
// allowing commands to participate in the Core lifecycle.
// WithCommands returns a CommandSetup that registers a command group.
// The register function receives the root cobra command during Main().
//
// cli.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
// WithCommands creates a framework Option that registers a command group.
// Optionally pass a locale fs.FS as the third argument to provide translations.
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup {
return func(c *core.Core) {
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
if root, ok := c.App().Runtime.(*cobra.Command); ok {
register(root)
}
appendLocales(localeFS...)
}
}
// CommandRegistration is a function that adds commands to the CLI root.
//
// cli.WithCommands("dev", dev.AddDevCommands, locales.FS)
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) core.Option {
return core.WithName("cmd."+name, func(c *core.Core) (any, error) {
svc := &commandService{core: c, name: name, register: register}
if len(localeFS) > 0 {
svc.localeFS = localeFS[0]
}
return svc, nil
})
}
type commandService struct {
core *core.Core
name string
register func(root *Command)
localeFS fs.FS
}
func (s *commandService) OnStartup(_ context.Context) error {
if root, ok := s.core.App.(*cobra.Command); ok {
s.register(root)
// Auto-set Short/Long from i18n keys derived from command name.
// The Conclave's i18n service has already loaded all translations
// from sibling services' LocaleProvider before commands attach.
s.applyI18n(root)
}
return nil
}
// applyI18n walks commands added by this service and sets Short/Long
// from derived i18n keys if they're empty or still raw keys.
func (s *commandService) applyI18n(root *cobra.Command) {
for _, cmd := range root.Commands() {
key := "cmd." + cmd.Name()
// Only set if Short is empty or looks like a raw key (contains dots)
if cmd.Short == "" || cmd.Short == key+".short" {
if translated := T(key + ".short"); translated != key+".short" {
cmd.Short = translated
}
}
if cmd.Long == "" || cmd.Long == key+".long" {
if translated := T(key + ".long"); translated != key+".long" {
cmd.Long = translated
}
}
}
}
// Locales implements core.LocaleProvider.
func (s *commandService) Locales() fs.FS {
return s.localeFS
}
// CommandRegistration is a function that adds commands to the root.
// Example:
// func addCommands(root *cobra.Command) {
// root.AddCommand(cli.NewRun("ping", "Ping API", "", func(cmd *cli.Command, args []string) {
// cli.Println("pong")
// }))
// }
type CommandRegistration func(root *cobra.Command)
var (
@ -91,35 +51,101 @@ var (
// func init() {
// cli.RegisterCommands(AddCommands, locales.FS)
// }
//
// Example:
// cli.RegisterCommands(func(root *cobra.Command) {
// root.AddCommand(cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
// cli.Println(cli.SemVer())
// }))
// })
func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
registeredCommands = append(registeredCommands, fn)
for _, lfs := range localeFS {
if lfs != nil {
registeredLocales = append(registeredLocales, lfs)
}
}
attached := commandsAttached && instance != nil && instance.root != nil
root := instance
registeredCommandsMu.Unlock()
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
appendLocales(localeFS...)
// If commands already attached (CLI already running), attach immediately
if commandsAttached && instance != nil && instance.root != nil {
fn(instance.root)
if attached {
fn(root.root)
}
}
// appendLocales appends non-nil locale filesystems to the registry.
func appendLocales(localeFS ...fs.FS) {
var nonempty []fs.FS
for _, lfs := range localeFS {
if lfs != nil {
nonempty = append(nonempty, lfs)
}
}
if len(nonempty) == 0 {
return
}
registeredCommandsMu.Lock()
registeredLocales = append(registeredLocales, nonempty...)
registeredCommandsMu.Unlock()
}
func localeSourcesFromFS(localeFS ...fs.FS) []LocaleSource {
sources := make([]LocaleSource, 0, len(localeFS))
for _, lfs := range localeFS {
if lfs != nil {
sources = append(sources, LocaleSource{FS: lfs, Dir: "."})
}
}
return sources
}
func loadLocaleSources(sources ...LocaleSource) {
svc := i18n.Default()
if svc == nil {
return
}
for _, src := range sources {
if src.FS == nil {
continue
}
if err := svc.AddLoader(i18n.NewFSLoader(src.FS, src.Dir)); err != nil {
LogDebug("failed to load locale source", "dir", src.Dir, "err", err)
}
}
}
// RegisteredLocales returns all locale filesystems registered by command packages.
//
// Example:
// for _, fs := range cli.RegisteredLocales() {
// _ = fs
// }
func RegisteredLocales() []fs.FS {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
return registeredLocales
if len(registeredLocales) == 0 {
return nil
}
out := make([]fs.FS, len(registeredLocales))
copy(out, registeredLocales)
return out
}
// RegisteredCommands returns an iterator over the registered command functions.
//
// Example:
// for attach := range cli.RegisteredCommands() {
// _ = attach
// }
func RegisteredCommands() iter.Seq[CommandRegistration] {
return func(yield func(CommandRegistration) bool) {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
for _, fn := range registeredCommands {
snapshot := make([]CommandRegistration, len(registeredCommands))
copy(snapshot, registeredCommands)
registeredCommandsMu.Unlock()
for _, fn := range snapshot {
if !yield(fn) {
return
}
@ -131,11 +157,12 @@ func RegisteredCommands() iter.Seq[CommandRegistration] {
// Called by Init() after creating the root command.
func attachRegisteredCommands(root *cobra.Command) {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
snapshot := make([]CommandRegistration, len(registeredCommands))
copy(snapshot, registeredCommands)
commandsAttached = true
registeredCommandsMu.Unlock()
for _, fn := range registeredCommands {
for _, fn := range snapshot {
fn(root)
}
commandsAttached = true
}

View file

@ -12,21 +12,16 @@ import (
// resetGlobals clears the CLI singleton and command registry for test isolation.
func resetGlobals(t *testing.T) {
t.Helper()
t.Cleanup(func() {
// Restore clean state after each test.
registeredCommandsMu.Lock()
registeredCommands = nil
commandsAttached = false
registeredCommandsMu.Unlock()
if instance != nil {
Shutdown()
}
instance = nil
once = sync.Once{}
})
doReset()
t.Cleanup(doReset)
}
// doReset clears all package-level state. Only safe from a single goroutine
// with no concurrent RegisterCommands calls in flight (i.e. test setup/teardown).
func doReset() {
registeredCommandsMu.Lock()
registeredCommands = nil
registeredLocales = nil
commandsAttached = false
registeredCommandsMu.Unlock()
if instance != nil {
@ -164,3 +159,28 @@ func TestWithAppName_Good(t *testing.T) {
})
}
// TestRegisterCommands_Ugly tests edge cases and concurrent registration.
func TestRegisterCommands_Ugly(t *testing.T) {
t.Run("register nil function does not panic", func(t *testing.T) {
resetGlobals(t)
// Registering a nil function should not panic at registration time.
assert.NotPanics(t, func() {
RegisterCommands(nil)
})
})
t.Run("re-init after shutdown is idempotent", func(t *testing.T) {
resetGlobals(t)
err := Init(Options{AppName: "test"})
require.NoError(t, err)
Shutdown()
resetGlobals(t)
err = Init(Options{AppName: "test"})
require.NoError(t, err)
assert.NotNil(t, RootCmd())
})
}

View file

@ -8,6 +8,11 @@ import (
)
// Mode represents the CLI execution mode.
//
// mode := cli.DetectMode()
// if mode == cli.ModeDaemon {
// cli.LogInfo("running headless")
// }
type Mode int
const (
@ -34,7 +39,11 @@ func (m Mode) String() string {
}
// DetectMode determines the execution mode based on environment.
// Checks CORE_DAEMON env var first, then TTY status.
//
// mode := cli.DetectMode()
// // cli.ModeDaemon when CORE_DAEMON=1
// // cli.ModePipe when stdout is not a terminal
// // cli.ModeInteractive otherwise
func DetectMode() Mode {
if os.Getenv("CORE_DAEMON") == "1" {
return ModeDaemon
@ -46,17 +55,37 @@ func DetectMode() Mode {
}
// IsTTY returns true if stdout is a terminal.
//
// if cli.IsTTY() {
// cli.Success("interactive output enabled")
// }
func IsTTY() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
if f, ok := stdoutWriter().(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
// IsStdinTTY returns true if stdin is a terminal.
//
// if !cli.IsStdinTTY() {
// cli.Warn("input is piped")
// }
func IsStdinTTY() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
if f, ok := stdinReader().(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
// IsStderrTTY returns true if stderr is a terminal.
//
// if cli.IsStderrTTY() {
// cli.Progress("load", 1, 3, "config")
// }
func IsStderrTTY() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
if f, ok := stderrWriter().(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}

View file

@ -1,44 +0,0 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddDaemonCommand_RegistersSubcommands(t *testing.T) {
root := &Command{Use: "test"}
AddDaemonCommand(root, DaemonCommandConfig{
Name: "daemon",
PIDFile: "/tmp/test-daemon.pid",
HealthAddr: "127.0.0.1:0",
})
// Should have the daemon command
daemonCmd, _, err := root.Find([]string{"daemon"})
require.NoError(t, err)
require.NotNil(t, daemonCmd)
// Should have subcommands
var subNames []string
for _, sub := range daemonCmd.Commands() {
subNames = append(subNames, sub.Name())
}
assert.Contains(t, subNames, "start")
assert.Contains(t, subNames, "stop")
assert.Contains(t, subNames, "status")
assert.Contains(t, subNames, "run")
}
func TestDaemonCommandConfig_DefaultName(t *testing.T) {
root := &Command{Use: "test"}
AddDaemonCommand(root, DaemonCommandConfig{})
// Should default to "daemon"
daemonCmd, _, err := root.Find([]string{"daemon"})
require.NoError(t, err)
require.NotNil(t, daemonCmd)
}

322
pkg/cli/daemon_process.go Normal file
View file

@ -0,0 +1,322 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// DaemonOptions configures a background process helper.
//
// daemon := cli.NewDaemon(cli.DaemonOptions{
// PIDFile: "/tmp/core.pid",
// HealthAddr: "127.0.0.1:8080",
// })
type DaemonOptions struct {
// PIDFile stores the current process ID on Start and removes it on Stop.
PIDFile string
// HealthAddr binds the HTTP health server.
// Pass an empty string to disable the server.
HealthAddr string
// HealthPath serves the liveness probe endpoint.
HealthPath string
// ReadyPath serves the readiness probe endpoint.
ReadyPath string
// HealthCheck reports whether the process is healthy.
// Defaults to true when nil.
HealthCheck func() bool
// ReadyCheck reports whether the process is ready to serve traffic.
// Defaults to HealthCheck when nil, or true when both are nil.
ReadyCheck func() bool
}
// Daemon manages a PID file and optional HTTP health endpoints.
//
// daemon := cli.NewDaemon(cli.DaemonOptions{PIDFile: "/tmp/core.pid"})
// _ = daemon.Start(context.Background())
type Daemon struct {
opts DaemonOptions
mu sync.Mutex
listener net.Listener
server *http.Server
addr string
started bool
}
var (
processNow = time.Now
processSleep = time.Sleep
processAlive = func(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
err = proc.Signal(syscall.Signal(0))
return err == nil || errors.Is(err, syscall.EPERM)
}
processSignal = func(pid int, sig syscall.Signal) error {
proc, err := os.FindProcess(pid)
if err != nil {
return err
}
return proc.Signal(sig)
}
processPollInterval = 100 * time.Millisecond
processShutdownWait = 30 * time.Second
)
// NewDaemon creates a daemon helper with sensible defaults.
func NewDaemon(opts DaemonOptions) *Daemon {
if opts.HealthPath == "" {
opts.HealthPath = "/health"
}
if opts.ReadyPath == "" {
opts.ReadyPath = "/ready"
}
return &Daemon{opts: opts}
}
// Start writes the PID file and starts the health server, if configured.
func (d *Daemon) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
d.mu.Lock()
defer d.mu.Unlock()
if d.started {
return nil
}
if err := d.writePIDFile(); err != nil {
return err
}
if d.opts.HealthAddr != "" {
if err := d.startHealthServer(ctx); err != nil {
_ = d.removePIDFile()
return err
}
}
d.started = true
return nil
}
// Stop shuts down the health server and removes the PID file.
func (d *Daemon) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
d.mu.Lock()
server := d.server
listener := d.listener
d.server = nil
d.listener = nil
d.addr = ""
d.started = false
d.mu.Unlock()
var firstErr error
if server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil && !isClosedServerError(err) {
firstErr = err
}
}
if listener != nil {
if err := listener.Close(); err != nil && !isListenerClosedError(err) && firstErr == nil {
firstErr = err
}
}
if err := d.removePIDFile(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
// HealthAddr returns the bound health server address, if running.
func (d *Daemon) HealthAddr() string {
d.mu.Lock()
defer d.mu.Unlock()
if d.addr != "" {
return d.addr
}
return d.opts.HealthAddr
}
// StopPIDFile sends SIGTERM to the process identified by pidFile, waits for it
// to exit, escalates to SIGKILL after the timeout, and then removes the file.
//
// If the PID file does not exist, StopPIDFile returns nil.
func StopPIDFile(pidFile string, timeout time.Duration) error {
if pidFile == "" {
return nil
}
if timeout <= 0 {
timeout = processShutdownWait
}
rawPID, err := os.ReadFile(pidFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
pid, err := parsePID(strings.TrimSpace(string(rawPID)))
if err != nil {
return fmt.Errorf("parse pid file %q: %w", pidFile, err)
}
if err := processSignal(pid, syscall.SIGTERM); err != nil && !isProcessGone(err) {
return err
}
deadline := processNow().Add(timeout)
for processAlive(pid) && processNow().Before(deadline) {
processSleep(processPollInterval)
}
if processAlive(pid) {
if err := processSignal(pid, syscall.SIGKILL); err != nil && !isProcessGone(err) {
return err
}
deadline = processNow().Add(processShutdownWait)
for processAlive(pid) && processNow().Before(deadline) {
processSleep(processPollInterval)
}
if processAlive(pid) {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
}
return os.Remove(pidFile)
}
func parsePID(raw string) (int, error) {
if raw == "" {
return 0, fmt.Errorf("empty pid")
}
pid, err := strconv.Atoi(raw)
if err != nil {
return 0, err
}
if pid <= 0 {
return 0, fmt.Errorf("invalid pid %d", pid)
}
return pid, nil
}
func isProcessGone(err error) bool {
return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH)
}
func (d *Daemon) writePIDFile() error {
if d.opts.PIDFile == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil {
return err
}
return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644)
}
func (d *Daemon) removePIDFile() error {
if d.opts.PIDFile == "" {
return nil
}
if err := os.Remove(d.opts.PIDFile); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (d *Daemon) startHealthServer(ctx context.Context) error {
mux := http.NewServeMux()
healthCheck := d.opts.HealthCheck
if healthCheck == nil {
healthCheck = func() bool { return true }
}
readyCheck := d.opts.ReadyCheck
if readyCheck == nil {
readyCheck = healthCheck
}
mux.HandleFunc(d.opts.HealthPath, func(w http.ResponseWriter, r *http.Request) {
writeProbe(w, healthCheck())
})
mux.HandleFunc(d.opts.ReadyPath, func(w http.ResponseWriter, r *http.Request) {
writeProbe(w, readyCheck())
})
listener, err := net.Listen("tcp", d.opts.HealthAddr)
if err != nil {
return err
}
server := &http.Server{
Handler: mux,
BaseContext: func(net.Listener) context.Context {
return ctx
},
}
d.listener = listener
d.server = server
d.addr = listener.Addr().String()
go func() {
err := server.Serve(listener)
if err != nil && !isClosedServerError(err) {
_ = err
}
}()
return nil
}
func writeProbe(w http.ResponseWriter, ok bool) {
if ok {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok\n")
return
}
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, "unhealthy\n")
}
func isClosedServerError(err error) bool {
return err == nil || err == http.ErrServerClosed
}
func isListenerClosedError(err error) bool {
return err == nil || errors.Is(err, net.ErrClosed)
}

View file

@ -0,0 +1,199 @@
package cli
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDaemon_StartStop(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
ready := false
daemon := NewDaemon(DaemonOptions{
PIDFile: pidFile,
HealthAddr: "127.0.0.1:0",
HealthCheck: func() bool {
return true
},
ReadyCheck: func() bool {
return ready
},
})
require.NoError(t, daemon.Start(context.Background()))
defer func() {
require.NoError(t, daemon.Stop(context.Background()))
}()
rawPID, err := os.ReadFile(pidFile)
require.NoError(t, err)
assert.Equal(t, strconv.Itoa(os.Getpid()), strings.TrimSpace(string(rawPID)))
addr := daemon.HealthAddr()
require.NotEmpty(t, addr)
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get("http://" + addr + "/health")
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ok\n", string(body))
resp, err = client.Get("http://" + addr + "/ready")
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, "unhealthy\n", string(body))
ready = true
resp, err = client.Get("http://" + addr + "/ready")
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ok\n", string(body))
}
func TestDaemon_StopRemovesPIDFile(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
daemon := NewDaemon(DaemonOptions{PIDFile: pidFile})
require.NoError(t, daemon.Start(context.Background()))
_, err := os.Stat(pidFile)
require.NoError(t, err)
require.NoError(t, daemon.Stop(context.Background()))
_, err = os.Stat(pidFile)
require.Error(t, err)
assert.True(t, os.IsNotExist(err))
}
func TestStopPIDFile_Good(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
require.NoError(t, os.WriteFile(pidFile, []byte("1234\n"), 0o644))
originalSignal := processSignal
originalAlive := processAlive
originalNow := processNow
originalSleep := processSleep
originalPoll := processPollInterval
originalShutdownWait := processShutdownWait
t.Cleanup(func() {
processSignal = originalSignal
processAlive = originalAlive
processNow = originalNow
processSleep = originalSleep
processPollInterval = originalPoll
processShutdownWait = originalShutdownWait
})
var mu sync.Mutex
var signals []syscall.Signal
processSignal = func(pid int, sig syscall.Signal) error {
mu.Lock()
signals = append(signals, sig)
mu.Unlock()
return nil
}
processAlive = func(pid int) bool {
mu.Lock()
defer mu.Unlock()
if len(signals) == 0 {
return true
}
return signals[len(signals)-1] != syscall.SIGTERM
}
processPollInterval = 0
processShutdownWait = 0
require.NoError(t, StopPIDFile(pidFile, time.Second))
mu.Lock()
defer mu.Unlock()
require.Equal(t, []syscall.Signal{syscall.SIGTERM}, signals)
_, err := os.Stat(pidFile)
require.Error(t, err)
assert.True(t, os.IsNotExist(err))
}
func TestStopPIDFile_Bad_Escalates(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
require.NoError(t, os.WriteFile(pidFile, []byte("4321\n"), 0o644))
originalSignal := processSignal
originalAlive := processAlive
originalNow := processNow
originalSleep := processSleep
originalPoll := processPollInterval
originalShutdownWait := processShutdownWait
t.Cleanup(func() {
processSignal = originalSignal
processAlive = originalAlive
processNow = originalNow
processSleep = originalSleep
processPollInterval = originalPoll
processShutdownWait = originalShutdownWait
})
var mu sync.Mutex
var signals []syscall.Signal
current := time.Unix(0, 0)
processNow = func() time.Time {
mu.Lock()
defer mu.Unlock()
return current
}
processSleep = func(d time.Duration) {
mu.Lock()
current = current.Add(d)
mu.Unlock()
}
processSignal = func(pid int, sig syscall.Signal) error {
mu.Lock()
signals = append(signals, sig)
mu.Unlock()
return nil
}
processAlive = func(pid int) bool {
mu.Lock()
defer mu.Unlock()
if len(signals) == 0 {
return true
}
return signals[len(signals)-1] != syscall.SIGKILL
}
processPollInterval = 10 * time.Millisecond
processShutdownWait = 0
require.NoError(t, StopPIDFile(pidFile, 15*time.Millisecond))
mu.Lock()
defer mu.Unlock()
require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, signals)
}

View file

@ -6,16 +6,21 @@ import (
"github.com/stretchr/testify/assert"
)
func TestDetectMode(t *testing.T) {
t.Run("daemon mode from env", func(t *testing.T) {
t.Setenv("CORE_DAEMON", "1")
assert.Equal(t, ModeDaemon, DetectMode())
})
t.Run("mode string", func(t *testing.T) {
assert.Equal(t, "interactive", ModeInteractive.String())
assert.Equal(t, "pipe", ModePipe.String())
assert.Equal(t, "daemon", ModeDaemon.String())
assert.Equal(t, "unknown", Mode(99).String())
})
func TestDetectMode_Good(t *testing.T) {
t.Setenv("CORE_DAEMON", "1")
assert.Equal(t, ModeDaemon, DetectMode())
}
func TestDetectMode_Bad(t *testing.T) {
t.Setenv("CORE_DAEMON", "0")
mode := DetectMode()
assert.NotEqual(t, ModeDaemon, mode)
}
func TestDetectMode_Ugly(t *testing.T) {
// Mode.String() covers all branches including the default unknown case.
assert.Equal(t, "interactive", ModeInteractive.String())
assert.Equal(t, "pipe", ModePipe.String())
assert.Equal(t, "daemon", ModeDaemon.String())
assert.Equal(t, "unknown", Mode(99).String())
}

View file

@ -78,6 +78,12 @@ func Join(errs ...error) error {
}
// ExitError represents an error that should cause the CLI to exit with a specific code.
//
// err := cli.Exit(2, cli.Err("validation failed"))
// var exitErr *cli.ExitError
// if cli.As(err, &exitErr) {
// cli.Println("exit code:", exitErr.Code)
// }
type ExitError struct {
Code int
Err error
@ -95,7 +101,8 @@ func (e *ExitError) Unwrap() error {
}
// Exit creates a new ExitError with the given code and error.
// Use this to return an error from a command with a specific exit code.
//
// return cli.Exit(2, cli.Err("validation failed"))
func Exit(code int, err error) error {
if err == nil {
return nil
@ -113,7 +120,7 @@ func Exit(code int, err error) error {
func Fatal(err error) {
if err != nil {
LogError("Fatal error", "err", err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
os.Exit(1)
}
}
@ -124,7 +131,7 @@ func Fatal(err error) {
func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
LogError("Fatal error", "msg", msg)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
os.Exit(1)
}
@ -140,7 +147,7 @@ func FatalWrap(err error, msg string) {
}
LogError("Fatal error", "msg", msg, "err", err)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}
@ -157,6 +164,6 @@ func FatalWrapVerb(err error, verb, subject string) {
msg := i18n.ActionFailed(verb, subject)
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}

76
pkg/cli/errors_test.go Normal file
View file

@ -0,0 +1,76 @@
package cli
import (
"errors"
"strings"
"testing"
)
func TestErrors_Good(t *testing.T) {
// Err creates a formatted error.
err := Err("key not found: %s", "theme")
if err == nil {
t.Fatal("Err: expected non-nil error")
}
if !strings.Contains(err.Error(), "theme") {
t.Errorf("Err: expected 'theme' in message, got %q", err.Error())
}
// Wrap prepends a message.
base := errors.New("connection refused")
wrapped := Wrap(base, "connect to database")
if !strings.Contains(wrapped.Error(), "connect to database") {
t.Errorf("Wrap: expected prefix in message, got %q", wrapped.Error())
}
if !Is(wrapped, base) {
t.Error("Wrap: errors.Is should unwrap to original")
}
}
func TestErrors_Bad(t *testing.T) {
// Wrap with nil error returns nil.
if Wrap(nil, "should be nil") != nil {
t.Error("Wrap(nil): expected nil return")
}
// WrapVerb with nil error returns nil.
if WrapVerb(nil, "load", "config") != nil {
t.Error("WrapVerb(nil): expected nil return")
}
// WrapAction with nil error returns nil.
if WrapAction(nil, "connect") != nil {
t.Error("WrapAction(nil): expected nil return")
}
}
func TestErrors_Ugly(t *testing.T) {
// Join with multiple errors.
err1 := Err("first error")
err2 := Err("second error")
joined := Join(err1, err2)
if joined == nil {
t.Fatal("Join: expected non-nil error")
}
if !Is(joined, err1) {
t.Error("Join: errors.Is should find first error")
}
// Exit creates ExitError with correct code.
exitErr := Exit(2, Err("exit with code 2"))
if exitErr == nil {
t.Fatal("Exit: expected non-nil error")
}
var exitErrorValue *ExitError
if !As(exitErr, &exitErrorValue) {
t.Fatal("Exit: expected *ExitError type")
}
if exitErrorValue.Code != 2 {
t.Errorf("Exit: expected code 2, got %d", exitErrorValue.Code)
}
// Exit with nil returns nil.
if Exit(1, nil) != nil {
t.Error("Exit(nil): expected nil return")
}
}

View file

@ -10,6 +10,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term"
)
@ -60,7 +61,7 @@ func NewFrame(variant string) *Frame {
variant: variant,
layout: Layout(variant),
models: make(map[Region]Model),
out: os.Stdout,
out: stderrWriter(),
done: make(chan struct{}),
focused: RegionContent,
keyMap: DefaultKeyMap(),
@ -69,6 +70,15 @@ func NewFrame(variant string) *Frame {
}
}
// WithOutput sets the destination writer for rendered output.
// Pass nil to keep the current writer unchanged.
func (f *Frame) WithOutput(out io.Writer) *Frame {
if out != nil {
f.out = out
}
return f
}
// Header sets the Header region model.
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
@ -428,6 +438,7 @@ func (f *Frame) String() string {
if view == "" {
return ""
}
view = ansi.Strip(view)
// Ensure trailing newline for non-TTY consistency
if !strings.HasSuffix(view, "\n") {
view += "\n"
@ -452,12 +463,11 @@ func (f *Frame) termSize() (int, int) {
return 80, 24 // sensible default
}
func (f *Frame) runLive() {
opts := []tea.ProgramOption{
tea.WithAltScreen(),
}
if f.out != os.Stdout {
if f.out != stdoutWriter() {
opts = append(opts, tea.WithOutput(f.out))
}

View file

@ -20,9 +20,9 @@ func StatusLine(title string, pairs ...string) Model {
}
func (s *statusLineModel) View(width, _ int) string {
parts := []string{BoldStyle.Render(s.title)}
parts := []string{BoldStyle.Render(compileGlyphs(s.title))}
for _, p := range s.pairs {
parts = append(parts, DimStyle.Render(p))
parts = append(parts, DimStyle.Render(compileGlyphs(p)))
}
line := strings.Join(parts, " ")
if width > 0 {
@ -46,7 +46,7 @@ func KeyHints(hints ...string) Model {
func (k *keyHintsModel) View(width, _ int) string {
parts := make([]string, len(k.hints))
for i, h := range k.hints {
parts[i] = DimStyle.Render(h)
parts[i] = DimStyle.Render(compileGlyphs(h))
}
line := strings.Join(parts, " ")
if width > 0 {
@ -70,10 +70,11 @@ func Breadcrumb(parts ...string) Model {
func (b *breadcrumbModel) View(width, _ int) string {
styled := make([]string, len(b.parts))
for i, p := range b.parts {
part := compileGlyphs(p)
if i == len(b.parts)-1 {
styled[i] = BoldStyle.Render(p)
styled[i] = BoldStyle.Render(part)
} else {
styled[i] = DimStyle.Render(p)
styled[i] = DimStyle.Render(part)
}
}
line := strings.Join(styled, DimStyle.Render(" > "))
@ -94,5 +95,5 @@ func StaticModel(text string) Model {
}
func (s *staticModel) View(_, _ int) string {
return s.text
return compileGlyphs(s.text)
}

View file

@ -0,0 +1,65 @@
package cli
import (
"strings"
"testing"
)
func TestFrameComponents_Good(t *testing.T) {
// StatusLine renders title and pairs.
model := StatusLine("core dev", "18 repos", "main")
output := model.View(80, 1)
if !strings.Contains(output, "core dev") {
t.Errorf("StatusLine: expected 'core dev' in output, got %q", output)
}
// KeyHints renders hints.
hints := KeyHints("↑/↓ navigate", "enter select", "q quit")
output = hints.View(80, 1)
if !strings.Contains(output, "navigate") {
t.Errorf("KeyHints: expected 'navigate' in output, got %q", output)
}
// Breadcrumb renders navigation path.
breadcrumb := Breadcrumb("core", "dev", "health")
output = breadcrumb.View(80, 1)
if !strings.Contains(output, "health") {
t.Errorf("Breadcrumb: expected 'health' in output, got %q", output)
}
// StaticModel returns static text.
static := StaticModel("static content")
output = static.View(80, 1)
if output != "static content" {
t.Errorf("StaticModel: expected 'static content', got %q", output)
}
}
func TestFrameComponents_Bad(t *testing.T) {
// StatusLine with zero width should truncate to empty or short string.
model := StatusLine("long title that should be truncated")
output := model.View(0, 1)
// Zero width means no truncation guard in current impl — just verify no panic.
_ = output
// KeyHints with no hints should not panic.
hints := KeyHints()
output = hints.View(80, 1)
_ = output
}
func TestFrameComponents_Ugly(t *testing.T) {
// Breadcrumb with single item has no separator.
breadcrumb := Breadcrumb("root")
output := breadcrumb.View(80, 1)
if !strings.Contains(output, "root") {
t.Errorf("Breadcrumb single: expected 'root', got %q", output)
}
// StatusLine with very narrow width truncates output.
model := StatusLine("core dev", "18 repos")
output = model.View(5, 1)
if len(output) > 10 {
t.Errorf("StatusLine truncated: output too long for width 5, got %q", output)
}
}

View file

@ -551,3 +551,40 @@ func TestFrameMessageRouting_Good(t *testing.T) {
})
}
func TestFrame_Ugly(t *testing.T) {
t.Run("navigate with nil model does not panic", func(t *testing.T) {
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Content(StaticModel("base"))
assert.NotPanics(t, func() {
f.Navigate(nil)
})
})
t.Run("deeply nested back stack does not panic", func(t *testing.T) {
f := NewFrame("C")
f.out = &bytes.Buffer{}
f.Content(StaticModel("p0"))
for i := 1; i <= 20; i++ {
f.Navigate(StaticModel("p" + string(rune('0'+i%10))))
}
for f.Back() {
// drain the full history stack
}
assert.False(t, f.Back(), "no more history after full drain")
})
t.Run("zero-size window renders without panic", func(t *testing.T) {
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Content(StaticModel("x"))
f.width = 0
f.height = 0
assert.NotPanics(t, func() {
_ = f.View()
})
})
}

View file

@ -20,15 +20,24 @@ const (
var currentTheme = ThemeUnicode
// UseUnicode switches the glyph theme to Unicode.
func UseUnicode() { currentTheme = ThemeUnicode }
func UseUnicode() {
currentTheme = ThemeUnicode
restoreColorIfASCII()
}
// UseEmoji switches the glyph theme to Emoji.
func UseEmoji() { currentTheme = ThemeEmoji }
func UseEmoji() {
currentTheme = ThemeEmoji
restoreColorIfASCII()
}
// UseASCII switches the glyph theme to ASCII and disables colors.
func UseASCII() {
currentTheme = ThemeASCII
SetColorEnabled(false)
colorEnabledMu.Lock()
asciiDisabledColors = true
colorEnabledMu.Unlock()
}
func glyphMap() map[string]string {

View file

@ -2,7 +2,8 @@ package cli
import "testing"
func TestGlyph(t *testing.T) {
func TestGlyph_Good(t *testing.T) {
restoreThemeAndColors(t)
UseUnicode()
if Glyph(":check:") != "✓" {
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
@ -14,10 +15,49 @@ func TestGlyph(t *testing.T) {
}
}
func TestCompileGlyphs(t *testing.T) {
func TestGlyph_Bad(t *testing.T) {
restoreThemeAndColors(t)
// Unknown shortcode returns the shortcode unchanged.
UseUnicode()
got := Glyph(":unknown:")
if got != ":unknown:" {
t.Errorf("Unknown shortcode should return unchanged, got %q", got)
}
}
func TestGlyph_Ugly(t *testing.T) {
restoreThemeAndColors(t)
// Empty shortcode should not panic.
got := Glyph("")
if got != "" {
t.Errorf("Empty shortcode should return empty string, got %q", got)
}
}
func TestCompileGlyphs_Good(t *testing.T) {
restoreThemeAndColors(t)
UseUnicode()
got := compileGlyphs("Status: :check:")
if got != "Status: ✓" {
t.Errorf("Expected Status: ✓, got %s", got)
t.Errorf("Expected 'Status: ✓', got %q", got)
}
}
func TestCompileGlyphs_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseUnicode()
// Text with no shortcodes should be returned as-is.
got := compileGlyphs("no glyphs here")
if got != "no glyphs here" {
t.Errorf("Expected unchanged text, got %q", got)
}
}
func TestCompileGlyphs_Ugly(t *testing.T) {
restoreThemeAndColors(t)
// Empty string should not panic.
got := compileGlyphs("")
if got != "" {
t.Errorf("Empty string should return empty, got %q", got)
}
}

View file

@ -6,6 +6,9 @@ import (
// T translates a key using the CLI's i18n service.
// Falls back to the global i18n.T if CLI not initialised.
//
// label := cli.T("cmd.doctor.required")
// msg := cli.T("cmd.doctor.issues", map[string]any{"Count": 3})
func T(key string, args ...map[string]any) string {
if len(args) > 0 {
return i18n.T(key, args[0])

30
pkg/cli/i18n_test.go Normal file
View file

@ -0,0 +1,30 @@
package cli
import "testing"
func TestT_Good(t *testing.T) {
// T should return a non-empty string for any key
// (falls back to the key itself when no translation is found).
result := T("some.key")
if result == "" {
t.Error("T: returned empty string for unknown key")
}
}
func TestT_Bad(t *testing.T) {
// T with args map should not panic.
result := T("cmd.doctor.issues", map[string]any{"Count": 0})
if result == "" {
t.Error("T with args: returned empty string")
}
}
func TestT_Ugly(t *testing.T) {
// T with empty key should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("T(\"\") panicked: %v", r)
}
}()
_ = T("")
}

68
pkg/cli/io.go Normal file
View file

@ -0,0 +1,68 @@
package cli
import (
"io"
"os"
"sync"
)
var (
stdin io.Reader = os.Stdin
stdoutOverride io.Writer
stderrOverride io.Writer
ioMu sync.RWMutex
)
// SetStdin overrides the default stdin reader for testing.
// Pass nil to restore the real os.Stdin reader.
func SetStdin(r io.Reader) {
ioMu.Lock()
defer ioMu.Unlock()
if r == nil {
stdin = os.Stdin
return
}
stdin = r
}
// SetStdout overrides the default stdout writer.
// Pass nil to restore writes to os.Stdout.
func SetStdout(w io.Writer) {
ioMu.Lock()
defer ioMu.Unlock()
stdoutOverride = w
}
// SetStderr overrides the default stderr writer.
// Pass nil to restore writes to os.Stderr.
func SetStderr(w io.Writer) {
ioMu.Lock()
defer ioMu.Unlock()
stderrOverride = w
}
func stdinReader() io.Reader {
ioMu.RLock()
defer ioMu.RUnlock()
return stdin
}
func stdoutWriter() io.Writer {
ioMu.RLock()
defer ioMu.RUnlock()
if stdoutOverride != nil {
return stdoutOverride
}
return os.Stdout
}
func stderrWriter() io.Writer {
ioMu.RLock()
defer ioMu.RUnlock()
if stderrOverride != nil {
return stderrOverride
}
return os.Stderr
}

View file

@ -68,7 +68,7 @@ type Renderable interface {
type StringBlock string
// Render returns the string content.
func (s StringBlock) Render() string { return string(s) }
func (s StringBlock) Render() string { return compileGlyphs(string(s)) }
// Layout creates a new layout from a variant string.
func Layout(variant string) *Composite {

View file

@ -2,24 +2,49 @@ package cli
import "testing"
func TestParseVariant(t *testing.T) {
c, err := ParseVariant("H[LC]F")
func TestParseVariant_Good(t *testing.T) {
composite, err := ParseVariant("H[LC]F")
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if _, ok := c.regions[RegionHeader]; !ok {
if _, ok := composite.regions[RegionHeader]; !ok {
t.Error("Expected Header region")
}
if _, ok := c.regions[RegionFooter]; !ok {
if _, ok := composite.regions[RegionFooter]; !ok {
t.Error("Expected Footer region")
}
hSlot := c.regions[RegionHeader]
if hSlot.child == nil {
t.Error("Header should have child layout")
headerSlot := composite.regions[RegionHeader]
if headerSlot.child == nil {
t.Error("Header should have child layout for H[LC]")
} else {
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
if _, ok := headerSlot.child.regions[RegionLeft]; !ok {
t.Error("Child should have Left region")
}
}
}
func TestParseVariant_Bad(t *testing.T) {
// Invalid region character.
_, err := ParseVariant("X")
if err == nil {
t.Error("Expected error for invalid region character 'X'")
}
// Unmatched bracket.
_, err = ParseVariant("H[C")
if err == nil {
t.Error("Expected error for unmatched bracket")
}
}
func TestParseVariant_Ugly(t *testing.T) {
// Empty variant should produce empty composite without panic.
composite, err := ParseVariant("")
if err != nil {
t.Fatalf("Empty variant should not error: %v", err)
}
if len(composite.regions) != 0 {
t.Errorf("Empty variant should have no regions, got %d", len(composite.regions))
}
}

View file

@ -12,7 +12,9 @@
"install_missing": "Install missing tools:",
"install_macos": "brew install",
"install_macos_cask": "brew install --cask",
"install_macos_go": "brew install go",
"install_linux_header": "Install on Linux:",
"install_linux_go": "sudo apt install golang-go",
"install_linux_git": "sudo apt install git",
"install_linux_node": "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs",
"install_linux_php": "sudo apt install php php-cli php-mbstring php-xml php-curl",
@ -30,6 +32,7 @@
"no_repos_yaml": "No repos.yaml found (run from workspace root)",
"check": {
"git": { "name": "Git", "description": "Version control" },
"go": { "name": "Go", "description": "Go compiler" },
"docker": { "name": "Docker", "description": "Container runtime" },
"node": { "name": "Node.js", "description": "JavaScript runtime" },
"php": { "name": "PHP", "description": "PHP interpreter" },
@ -108,7 +111,10 @@
"all_up_to_date": "All packages are up to date",
"commits_behind": "{{.Count}} commits behind",
"update_with": "Update with: core pkg update {{.Name}}",
"summary": "{{.Outdated}}/{{.Total}} outdated"
"summary": "{{.Outdated}}/{{.Total}} outdated",
"flag": {
"format": "Output format: table or json"
}
}
}
},

View file

@ -1,6 +1,8 @@
package cli
import (
"fmt"
"forge.lthn.ai/core/go-log"
)
@ -16,13 +18,33 @@ const (
)
// LogDebug logs a debug message if the default logger is available.
//
// cli.LogDebug("cache miss", "key", cacheKey)
func LogDebug(msg string, keyvals ...any) { log.Debug(msg, keyvals...) }
// LogInfo logs an info message.
//
// cli.LogInfo("configuration reloaded", "path", configPath)
func LogInfo(msg string, keyvals ...any) { log.Info(msg, keyvals...) }
// LogWarn logs a warning message.
//
// cli.LogWarn("GitHub CLI not authenticated", "user", username)
func LogWarn(msg string, keyvals ...any) { log.Warn(msg, keyvals...) }
// LogError logs an error message.
//
// cli.LogError("Fatal error", "err", err)
func LogError(msg string, keyvals ...any) { log.Error(msg, keyvals...) }
// LogSecurity logs a security-sensitive message.
//
// cli.LogSecurity("login attempt", "user", "admin")
func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
// LogSecurityf logs a formatted security-sensitive message.
//
// cli.LogSecurityf("login attempt from %s", username)
func LogSecurityf(format string, args ...any) {
log.Security(fmt.Sprintf(format, args...))
}

43
pkg/cli/log_test.go Normal file
View file

@ -0,0 +1,43 @@
package cli
import "testing"
func TestLog_Good(t *testing.T) {
// All log functions should not panic when called without a configured logger.
defer func() {
if r := recover(); r != nil {
t.Errorf("LogInfo panicked: %v", r)
}
}()
LogInfo("test info message", "key", "value")
}
func TestLog_Bad(t *testing.T) {
// LogError should not panic with an empty message.
defer func() {
if r := recover(); r != nil {
t.Errorf("LogError panicked: %v", r)
}
}()
LogError("")
}
func TestLog_Ugly(t *testing.T) {
// All log levels should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("log function panicked: %v", r)
}
}()
LogDebug("debug", "k", "v")
LogInfo("info", "k", "v")
LogWarn("warn", "k", "v")
LogError("error", "k", "v")
// Level constants should be accessible.
_ = LogLevelQuiet
_ = LogLevelError
_ = LogLevelWarn
_ = LogLevelInfo
_ = LogLevelDebug
}

View file

@ -2,7 +2,6 @@ package cli
import (
"fmt"
"os"
"strings"
"forge.lthn.ai/core/go-i18n"
@ -10,35 +9,35 @@ import (
// Blank prints an empty line.
func Blank() {
fmt.Println()
fmt.Fprintln(stdoutWriter())
}
// Echo translates a key via i18n.T and prints with newline.
// No automatic styling - use Success/Error/Warn/Info for styled output.
func Echo(key string, args ...any) {
fmt.Println(i18n.T(key, args...))
fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...)))
}
// Print outputs formatted text (no newline).
// Glyph shortcodes like :check: are converted.
func Print(format string, args ...any) {
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...)))
fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
}
// Println outputs formatted text with newline.
// Glyph shortcodes like :check: are converted.
func Println(format string, args ...any) {
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...)))
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
}
// Text prints arguments like fmt.Println, but handling glyphs.
func Text(args ...any) {
fmt.Println(compileGlyphs(fmt.Sprint(args...)))
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...)))
}
// Success prints a success message with checkmark (green).
func Success(msg string) {
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg))
fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
}
// Successf prints a formatted success message.
@ -49,7 +48,7 @@ func Successf(format string, args ...any) {
// Error prints an error message with cross (red) to stderr and logs it.
func Error(msg string) {
LogError(msg)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
}
// Errorf prints a formatted error message to stderr and logs it.
@ -86,7 +85,7 @@ func ErrorWrapAction(err error, verb string) {
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
func Warn(msg string) {
LogWarn(msg)
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg))
fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
}
// Warnf prints a formatted warning message to stderr and logs it.
@ -96,7 +95,7 @@ func Warnf(format string, args ...any) {
// Info prints an info message with info symbol (blue).
func Info(msg string) {
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg))
fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
}
// Infof prints a formatted info message.
@ -106,33 +105,33 @@ func Infof(format string, args ...any) {
// Dim prints dimmed text.
func Dim(msg string) {
fmt.Println(DimStyle.Render(msg))
fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg)))
}
// Progress prints a progress indicator that overwrites the current line.
// Uses i18n.Progress for gerund form ("Checking...").
func Progress(verb string, current, total int, item ...string) {
msg := i18n.Progress(verb)
msg := compileGlyphs(i18n.Progress(verb))
if len(item) > 0 && item[0] != "" {
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, compileGlyphs(item[0]))
} else {
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
}
}
// ProgressDone clears the progress line.
func ProgressDone() {
fmt.Print("\033[2K\r")
fmt.Fprint(stderrWriter(), "\033[2K\r")
}
// Label prints a "Label: value" line.
func Label(word, value string) {
fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value)
fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
}
// Scanln reads from stdin.
func Scanln(a ...any) (int, error) {
return fmt.Scanln(a...)
return fmt.Fscanln(newReader(), a...)
}
// Task prints a task header: "[label] message"
@ -140,15 +139,16 @@ func Scanln(a ...any) (int, error) {
// cli.Task("php", "Running tests...") // [php] Running tests...
// cli.Task("go", i18n.Progress("build")) // [go] Building...
func Task(label, message string) {
fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message)
fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
}
// Section prints a section header: "── SECTION ──"
//
// cli.Section("audit") // ── AUDIT ──
func Section(name string) {
header := "── " + strings.ToUpper(name) + " ──"
fmt.Println(AccentStyle.Render(header))
dash := Glyph(":dash:")
header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash
fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header))
}
// Hint prints a labelled hint: "label: message"
@ -156,7 +156,7 @@ func Section(name string) {
// cli.Hint("install", "composer require vimeo/psalm")
// cli.Hint("fix", "core php fmt --fix")
func Hint(label, message string) {
fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message)
fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
}
// Severity prints a severity-styled message.
@ -179,7 +179,7 @@ func Severity(level, message string) {
default:
style = DimStyle
}
fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message)
fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
}
// Result prints a result line: "✓ message" or "✗ message"

View file

@ -4,98 +4,93 @@ import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func captureOutput(f func()) string {
oldOut := os.Stdout
oldErr := os.Stderr
r, w, _ := os.Pipe()
os.Stdout = w
os.Stderr = w
reader, writer, _ := os.Pipe()
os.Stdout = writer
os.Stderr = writer
f()
_ = w.Close()
_ = writer.Close()
os.Stdout = oldOut
os.Stderr = oldErr
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
_, _ = io.Copy(&buf, reader)
return buf.String()
}
func TestSemanticOutput(t *testing.T) {
func TestSemanticOutput_Good(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
SetColorEnabled(false)
defer SetColorEnabled(true)
// Test Success
out := captureOutput(func() {
Success("done")
})
if out == "" {
t.Error("Success output empty")
cases := []struct {
name string
fn func()
}{
{"Success", func() { Success("done") }},
{"Info", func() { Info("info") }},
{"Task", func() { Task("task", "msg") }},
{"Section", func() { Section("section") }},
{"Hint", func() { Hint("hint", "msg") }},
{"Result_pass", func() { Result(true, "pass") }},
}
// Test Error
out = captureOutput(func() {
Error("fail")
})
if out == "" {
t.Error("Error output empty")
}
// Test Warn
out = captureOutput(func() {
Warn("warn")
})
if out == "" {
t.Error("Warn output empty")
}
// Test Info
out = captureOutput(func() {
Info("info")
})
if out == "" {
t.Error("Info output empty")
}
// Test Task
out = captureOutput(func() {
Task("task", "msg")
})
if out == "" {
t.Error("Task output empty")
}
// Test Section
out = captureOutput(func() {
Section("section")
})
if out == "" {
t.Error("Section output empty")
}
// Test Hint
out = captureOutput(func() {
Hint("hint", "msg")
})
if out == "" {
t.Error("Hint output empty")
}
// Test Result
out = captureOutput(func() {
Result(true, "pass")
})
if out == "" {
t.Error("Result(true) output empty")
}
out = captureOutput(func() {
Result(false, "fail")
})
if out == "" {
t.Error("Result(false) output empty")
for _, testCase := range cases {
output := captureOutput(testCase.fn)
if output == "" {
t.Errorf("%s: output was empty", testCase.name)
}
}
}
func TestSemanticOutput_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
SetColorEnabled(false)
defer SetColorEnabled(true)
// Error and Warn go to stderr — both captured here.
errorOutput := captureOutput(func() { Error("fail") })
if errorOutput == "" {
t.Error("Error: output was empty")
}
warnOutput := captureOutput(func() { Warn("warn") })
if warnOutput == "" {
t.Error("Warn: output was empty")
}
failureOutput := captureOutput(func() { Result(false, "fail") })
if failureOutput == "" {
t.Error("Result(false): output was empty")
}
}
func TestSemanticOutput_Ugly(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
// Severity with various levels should not panic.
levels := []string{"critical", "high", "medium", "low", "unknown", ""}
for _, level := range levels {
output := captureOutput(func() { Severity(level, "test message") })
if output == "" {
t.Errorf("Severity(%q): output was empty", level)
}
}
// Section uppercases the name.
output := captureOutput(func() { Section("audit") })
if !strings.Contains(output, "AUDIT") {
t.Errorf("Section: expected AUDIT in output, got %q", output)
}
}

View file

@ -5,39 +5,42 @@ import (
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
)
var stdin io.Reader = os.Stdin
// SetStdin overrides the default stdin reader for testing.
func SetStdin(r io.Reader) { stdin = r }
// newReader wraps stdin in a bufio.Reader if it isn't one already.
func newReader() *bufio.Reader {
if br, ok := stdin.(*bufio.Reader); ok {
if br, ok := stdinReader().(*bufio.Reader); ok {
return br
}
return bufio.NewReader(stdin)
return bufio.NewReader(stdinReader())
}
// Prompt asks for text input with a default value.
func Prompt(label, defaultVal string) (string, error) {
label = compileGlyphs(label)
defaultVal = compileGlyphs(defaultVal)
if defaultVal != "" {
fmt.Printf("%s [%s]: ", label, defaultVal)
fmt.Fprintf(stderrWriter(), "%s [%s]: ", label, defaultVal)
} else {
fmt.Printf("%s: ", label)
fmt.Fprintf(stderrWriter(), "%s: ", label)
}
r := newReader()
input, err := r.ReadString('\n')
if err != nil {
return "", err
}
input = strings.TrimSpace(input)
if err != nil {
if !errors.Is(err, io.EOF) {
return "", err
}
if input == "" {
if defaultVal != "" {
return defaultVal, nil
}
return "", err
}
}
if input == "" {
return defaultVal, nil
}
@ -46,46 +49,62 @@ func Prompt(label, defaultVal string) (string, error) {
// Select presents numbered options and returns the selected value.
func Select(label string, options []string) (string, error) {
fmt.Println(label)
for i, opt := range options {
fmt.Printf(" %d. %s\n", i+1, opt)
if len(options) == 0 {
return "", nil
}
fmt.Printf("Choose [1-%d]: ", len(options))
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
for i, opt := range options {
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
}
fmt.Fprintf(stderrWriter(), "Choose [1-%d]: ", len(options))
r := newReader()
input, err := r.ReadString('\n')
if err != nil {
return "", err
if err != nil && strings.TrimSpace(input) == "" {
promptHint("No input received. Selection cancelled.")
return "", Wrap(err, "selection cancelled")
}
n, err := strconv.Atoi(strings.TrimSpace(input))
trimmed := strings.TrimSpace(input)
n, err := strconv.Atoi(trimmed)
if err != nil || n < 1 || n > len(options) {
return "", errors.New("invalid selection")
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(options)))
return "", Err("invalid selection %q: choose a number between 1 and %d", trimmed, len(options))
}
return options[n-1], nil
}
// MultiSelect presents checkboxes (space-separated numbers).
func MultiSelect(label string, options []string) ([]string, error) {
fmt.Println(label)
for i, opt := range options {
fmt.Printf(" %d. %s\n", i+1, opt)
if len(options) == 0 {
return []string{}, nil
}
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options))
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
for i, opt := range options {
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
}
fmt.Fprintf(stderrWriter(), "Choose (space-separated) [1-%d]: ", len(options))
r := newReader()
input, err := r.ReadString('\n')
if err != nil {
trimmed := strings.TrimSpace(input)
if err != nil && trimmed == "" {
return []string{}, nil
}
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
var selected []string
for _, s := range strings.Fields(input) {
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > len(options) {
continue
}
selected = append(selected, options[n-1])
selected, parseErr := parseMultiSelection(trimmed, len(options))
if parseErr != nil {
return nil, Wrap(parseErr, fmt.Sprintf("invalid selection %q", trimmed))
}
return selected, nil
selectedOptions := make([]string, 0, len(selected))
for _, idx := range selected {
selectedOptions = append(selectedOptions, options[idx])
}
return selectedOptions, nil
}

View file

@ -50,3 +50,44 @@ func TestMultiSelect_Good(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []string{"a", "c"}, vals)
}
func TestPrompt_Ugly(t *testing.T) {
t.Run("empty prompt label does not panic", func(t *testing.T) {
SetStdin(strings.NewReader("value\n"))
defer SetStdin(nil)
assert.NotPanics(t, func() {
_, _ = Prompt("", "")
})
})
t.Run("prompt with only whitespace input returns default", func(t *testing.T) {
SetStdin(strings.NewReader(" \n"))
defer SetStdin(nil)
val, err := Prompt("Name", "fallback")
assert.NoError(t, err)
// Either whitespace-trimmed empty returns default, or returns whitespace — no panic.
_ = val
})
}
func TestSelect_Ugly(t *testing.T) {
t.Run("empty choices does not panic", func(t *testing.T) {
SetStdin(strings.NewReader("1\n"))
defer SetStdin(nil)
assert.NotPanics(t, func() {
_, _ = Select("Pick", []string{})
})
})
t.Run("non-numeric input returns error without panic", func(t *testing.T) {
SetStdin(strings.NewReader("abc\n"))
defer SetStdin(nil)
assert.NotPanics(t, func() {
_, _ = Select("Pick", []string{"a", "b"})
})
})
}

View file

@ -6,6 +6,10 @@ import (
)
// RenderStyle controls how layouts are rendered.
//
// cli.UseRenderBoxed()
// frame := cli.NewFrame("HCF")
// fmt.Print(frame.String())
type RenderStyle int
// Render style constants for layout output.
@ -21,17 +25,23 @@ const (
var currentRenderStyle = RenderFlat
// UseRenderFlat sets the render style to flat (no borders).
//
// cli.UseRenderFlat()
func UseRenderFlat() { currentRenderStyle = RenderFlat }
// UseRenderSimple sets the render style to simple (--- separators).
//
// cli.UseRenderSimple()
func UseRenderSimple() { currentRenderStyle = RenderSimple }
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
//
// cli.UseRenderBoxed()
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
// Render outputs the layout to terminal.
func (c *Composite) Render() {
fmt.Print(c.String())
fmt.Fprint(stdoutWriter(), c.String())
}
// String returns the rendered layout.
@ -66,9 +76,9 @@ func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
indent := strings.Repeat(" ", depth)
switch currentRenderStyle {
case RenderBoxed:
sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n")
sb.WriteString(indent + Glyph(":tee:") + strings.Repeat(Glyph(":dash:"), 40) + Glyph(":tee:") + "\n")
case RenderSimple:
sb.WriteString(indent + strings.Repeat("─", 40) + "\n")
sb.WriteString(indent + strings.Repeat(Glyph(":dash:"), 40) + "\n")
}
}

48
pkg/cli/render_test.go Normal file
View file

@ -0,0 +1,48 @@
package cli
import (
"strings"
"testing"
)
func TestCompositeRender_Good(t *testing.T) {
UseRenderFlat()
composite := Layout("HCF")
composite.H("Header content").C("Body content").F("Footer content")
output := composite.String()
if !strings.Contains(output, "Header content") {
t.Errorf("Render flat: expected 'Header content' in output, got %q", output)
}
if !strings.Contains(output, "Body content") {
t.Errorf("Render flat: expected 'Body content' in output, got %q", output)
}
}
func TestCompositeRender_Bad(t *testing.T) {
// Rendering an empty composite should not panic and return empty string.
composite := Layout("HCF")
output := composite.String()
if output != "" {
t.Errorf("Empty composite render: expected empty string, got %q", output)
}
}
func TestCompositeRender_Ugly(t *testing.T) {
// RenderSimple and RenderBoxed styles add separators between sections.
UseRenderSimple()
defer UseRenderFlat()
composite := Layout("HCF")
composite.H("top").C("middle").F("bottom")
output := composite.String()
if output == "" {
t.Error("RenderSimple: expected non-empty output")
}
UseRenderBoxed()
output = composite.String()
if output == "" {
t.Error("RenderBoxed: expected non-empty output")
}
}

View file

@ -19,8 +19,9 @@ import (
"os/signal"
"sync"
"syscall"
"time"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core"
"github.com/spf13/cobra"
)
@ -38,10 +39,17 @@ type runtime struct {
}
// Options configures the CLI runtime.
//
// Example:
// opts := cli.Options{
// AppName: "core",
// Version: "1.0.0",
// }
type Options struct {
AppName string
Version string
Services []core.Option // Additional services to register
AppName string
Version string
Services []core.Service // Additional services to register
I18nSources []LocaleSource // Additional i18n translation sources
// OnReload is called when SIGHUP is received (daemon mode).
// Use for configuration reloading. Leave nil to ignore SIGHUP.
@ -50,6 +58,11 @@ type Options struct {
// Init initialises the global CLI runtime.
// Call this once at startup (typically in main.go or cmd.Execute).
//
// Example:
// err := cli.Init(cli.Options{AppName: "core"})
// if err != nil { panic(err) }
// defer cli.Shutdown()
func Init(opts Options) error {
var initErr error
once.Do(func() {
@ -63,25 +76,35 @@ func Init(opts Options) error {
SilenceUsage: true,
}
// Build signal service options
var signalOpts []SignalOption
// Create Core with app identity
c := core.New(core.Options{
{Key: "name", Value: opts.AppName},
})
c.App().Version = opts.Version
c.App().Runtime = rootCmd
// Register signal service
signalSvc := &signalService{
cancel: cancel,
sigChan: make(chan os.Signal, 1),
}
if opts.OnReload != nil {
signalOpts = append(signalOpts, WithReloadHandler(opts.OnReload))
signalSvc.onReload = opts.OnReload
}
c.Service("signal", core.Service{
OnStart: func() core.Result {
return signalSvc.start(ctx)
},
OnStop: func() core.Result {
return signalSvc.stop()
},
})
// Build options: app, signal service + any additional services
coreOpts := []core.Option{
core.WithApp(rootCmd),
core.WithName("signal", newSignalService(cancel, signalOpts...)),
}
coreOpts = append(coreOpts, opts.Services...)
coreOpts = append(coreOpts, core.WithServiceLock())
c, err := core.New(coreOpts...)
if err != nil {
initErr = err
cancel()
return
// Register additional services
for _, svc := range opts.Services {
if svc.Name != "" {
c.Service(svc.Name, svc)
}
}
instance = &runtime{
@ -91,11 +114,16 @@ func Init(opts Options) error {
cancel: cancel,
}
if err := c.ServiceStartup(ctx, nil); err != nil {
initErr = err
r := c.ServiceStartup(ctx, nil)
if !r.OK {
if err, ok := r.Value.(error); ok {
initErr = err
}
return
}
loadLocaleSources(opts.I18nSources...)
// Attach registered commands AFTER Core startup so i18n is available
attachRegisteredCommands(rootCmd)
})
@ -124,28 +152,101 @@ func RootCmd() *cobra.Command {
// Execute runs the CLI root command.
// Returns an error if the command fails.
//
// Example:
// if err := cli.Execute(); err != nil {
// cli.Warn("command failed:", "err", err)
// }
func Execute() error {
mustInit()
return instance.root.Execute()
}
// Run executes the CLI and watches an external context for cancellation.
// If the context is cancelled first, the runtime is shut down and the
// command error is returned if execution failed during shutdown.
//
// Example:
// ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// defer cancel()
// if err := cli.Run(ctx); err != nil {
// cli.Error(err.Error())
// }
func Run(ctx context.Context) error {
mustInit()
if ctx == nil {
ctx = context.Background()
}
errCh := make(chan error, 1)
go func() {
errCh <- Execute()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
Shutdown()
if err := <-errCh; err != nil {
return err
}
return ctx.Err()
}
}
// RunWithTimeout returns a shutdown helper that waits for the runtime to stop
// for up to timeout before giving up. It is intended for deferred cleanup.
//
// Example:
// stop := cli.RunWithTimeout(5 * time.Second)
// defer stop()
func RunWithTimeout(timeout time.Duration) func() {
return func() {
if timeout <= 0 {
Shutdown()
return
}
done := make(chan struct{})
go func() {
Shutdown()
close(done)
}()
select {
case <-done:
case <-time.After(timeout):
// Give up waiting, but let the shutdown goroutine finish in the background.
}
}
}
// Context returns the CLI's root context.
// Cancelled on SIGINT/SIGTERM.
//
// Example:
// if ctx := cli.Context(); ctx != nil {
// _ = ctx
// }
func Context() context.Context {
mustInit()
return instance.ctx
}
// Shutdown gracefully shuts down the CLI.
//
// Example:
// cli.Shutdown()
func Shutdown() {
if instance == nil {
return
}
instance.cancel()
_ = instance.core.ServiceShutdown(instance.ctx)
_ = instance.core.ServiceShutdown(context.WithoutCancel(instance.ctx))
}
// --- Signal Service (internal) ---
// --- Signal Srv (internal) ---
type signalService struct {
cancel context.CancelFunc
@ -154,30 +255,7 @@ type signalService struct {
shutdownOnce sync.Once
}
// SignalOption configures signal handling.
type SignalOption func(*signalService)
// WithReloadHandler sets a callback for SIGHUP.
func WithReloadHandler(fn func() error) SignalOption {
return func(s *signalService) {
s.onReload = fn
}
}
func newSignalService(cancel context.CancelFunc, opts ...SignalOption) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
svc := &signalService{
cancel: cancel,
sigChan: make(chan os.Signal, 1),
}
for _, opt := range opts {
opt(svc)
}
return svc, nil
}
}
func (s *signalService) OnStartup(ctx context.Context) error {
func (s *signalService) start(ctx context.Context) core.Result {
signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM}
if s.onReload != nil {
signals = append(signals, syscall.SIGHUP)
@ -207,13 +285,13 @@ func (s *signalService) OnStartup(ctx context.Context) error {
}
}()
return nil
return core.Result{OK: true}
}
func (s *signalService) OnShutdown(ctx context.Context) error {
func (s *signalService) stop() core.Result {
s.shutdownOnce.Do(func() {
signal.Stop(s.sigChan)
close(s.sigChan)
})
return nil
return core.Result{OK: true}
}

View file

@ -0,0 +1,79 @@
package cli
import (
"context"
"errors"
"sync"
"testing"
"time"
"dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRun_Good_ReturnsCommandError(t *testing.T) {
resetGlobals(t)
require.NoError(t, Init(Options{AppName: "test"}))
RootCmd().AddCommand(NewCommand("boom", "Boom", "", func(_ *Command, _ []string) error {
return errors.New("boom")
}))
RootCmd().SetArgs([]string{"boom"})
err := Run(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "boom")
}
func TestRun_Good_CancelledContext(t *testing.T) {
resetGlobals(t)
require.NoError(t, Init(Options{AppName: "test"}))
RootCmd().AddCommand(NewCommand("wait", "Wait", "", func(_ *Command, _ []string) error {
<-Context().Done()
return nil
}))
RootCmd().SetArgs([]string{"wait"})
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(25*time.Millisecond, cancel)
err := Run(ctx)
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
}
func TestRunWithTimeout_Good_ReturnsHelper(t *testing.T) {
resetGlobals(t)
finished := make(chan struct{})
var finishedOnce sync.Once
require.NoError(t, Init(Options{
AppName: "test",
Services: []core.Service{
{
Name: "slow-stop",
OnStop: func() core.Result {
time.Sleep(100 * time.Millisecond)
finishedOnce.Do(func() {
close(finished)
})
return core.Result{OK: true}
},
},
},
}))
start := time.Now()
RunWithTimeout(20 * time.Millisecond)()
require.Less(t, time.Since(start), 80*time.Millisecond)
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("shutdown did not complete")
}
}

54
pkg/cli/runtime_test.go Normal file
View file

@ -0,0 +1,54 @@
package cli
import "testing"
func TestRuntime_Good(t *testing.T) {
// Init with valid options should succeed.
err := Init(Options{
AppName: "test-cli",
Version: "0.0.1",
})
if err != nil {
t.Fatalf("Init: unexpected error: %v", err)
}
defer Shutdown()
// Core() returns non-nil after Init.
coreInstance := Core()
if coreInstance == nil {
t.Error("Core(): returned nil after Init")
}
// RootCmd() returns non-nil after Init.
rootCommand := RootCmd()
if rootCommand == nil {
t.Error("RootCmd(): returned nil after Init")
}
// Context() returns non-nil after Init.
ctx := Context()
if ctx == nil {
t.Error("Context(): returned nil after Init")
}
}
func TestRuntime_Bad(t *testing.T) {
// Shutdown when not initialised should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("Shutdown() panicked when not initialised: %v", r)
}
}()
// Reset singleton so this test can run standalone.
// We use a fresh Shutdown here — it should be a no-op.
Shutdown()
}
func TestRuntime_Ugly(t *testing.T) {
// Once is idempotent: calling Init twice should succeed.
err := Init(Options{AppName: "test-ugly"})
if err != nil {
t.Fatalf("Init (second call): unexpected error: %v", err)
}
defer Shutdown()
}

View file

@ -3,13 +3,16 @@ package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"unicode/utf8"
"github.com/mattn/go-runewidth"
)
// StreamOption configures a Stream.
//
// stream := cli.NewStream(cli.WithWordWrap(80))
// stream.Wait()
type StreamOption func(*Stream)
// WithWordWrap sets the word-wrap column width.
@ -17,7 +20,7 @@ func WithWordWrap(cols int) StreamOption {
return func(s *Stream) { s.wrap = cols }
}
// WithStreamOutput sets the output writer (default: os.Stdout).
// WithStreamOutput sets the output writer (default: stdoutWriter()).
func WithStreamOutput(w io.Writer) StreamOption {
return func(s *Stream) { s.out = w }
}
@ -38,13 +41,14 @@ type Stream struct {
wrap int
col int // current column position (visible characters)
done chan struct{}
once sync.Once
mu sync.Mutex
}
// NewStream creates a streaming text renderer.
func NewStream(opts ...StreamOption) *Stream {
s := &Stream{
out: os.Stdout,
out: stdoutWriter(),
done: make(chan struct{}),
}
for _, opt := range opts {
@ -60,11 +64,11 @@ func (s *Stream) Write(text string) {
if s.wrap <= 0 {
fmt.Fprint(s.out, text)
// Track column across newlines for Done() trailing-newline logic.
// Track visible width across newlines for Done() trailing-newline logic.
if idx := strings.LastIndex(text, "\n"); idx >= 0 {
s.col = utf8.RuneCountInString(text[idx+1:])
s.col = runewidth.StringWidth(text[idx+1:])
} else {
s.col += utf8.RuneCountInString(text)
s.col += runewidth.StringWidth(text)
}
return
}
@ -76,13 +80,14 @@ func (s *Stream) Write(text string) {
continue
}
if s.col >= s.wrap {
rw := runewidth.RuneWidth(r)
if rw > 0 && s.col > 0 && s.col+rw > s.wrap {
fmt.Fprintln(s.out)
s.col = 0
}
fmt.Fprint(s.out, string(r))
s.col++
s.col += rw
}
}
@ -105,12 +110,14 @@ func (s *Stream) WriteFrom(r io.Reader) error {
// Done signals that no more text will arrive.
func (s *Stream) Done() {
s.mu.Lock()
if s.col > 0 {
fmt.Fprintln(s.out) // ensure trailing newline
}
s.mu.Unlock()
close(s.done)
s.once.Do(func() {
s.mu.Lock()
if s.col > 0 {
fmt.Fprintln(s.out) // ensure trailing newline
}
s.mu.Unlock()
close(s.done)
})
}
// Wait blocks until Done is called.
@ -125,16 +132,24 @@ func (s *Stream) Column() int {
return s.col
}
// Captured returns the stream output as a string when using a bytes.Buffer.
// Panics if the output writer is not a *strings.Builder or fmt.Stringer.
// Captured returns the stream output as a string when the output writer is
// capture-capable. If the writer cannot be captured, it returns an empty string.
// Use CapturedOK when you need to distinguish that case.
func (s *Stream) Captured() string {
out, _ := s.CapturedOK()
return out
}
// CapturedOK returns the stream output and whether the configured writer
// supports capture.
func (s *Stream) CapturedOK() (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if sb, ok := s.out.(*strings.Builder); ok {
return sb.String()
return sb.String(), true
}
if st, ok := s.out.(fmt.Stringer); ok {
return st.String()
return st.String(), true
}
return ""
return "", false
}

View file

@ -157,3 +157,41 @@ func TestStream_Bad(t *testing.T) {
assert.Equal(t, "", buf.String())
})
}
func TestStream_Ugly(t *testing.T) {
t.Run("Write after Done does not panic", func(t *testing.T) {
var buf bytes.Buffer
s := NewStream(WithStreamOutput(&buf))
s.Done()
s.Wait()
assert.NotPanics(t, func() {
s.Write("late write")
})
})
t.Run("word wrap width of 1 does not panic", func(t *testing.T) {
var buf bytes.Buffer
s := NewStream(WithWordWrap(1), WithStreamOutput(&buf))
assert.NotPanics(t, func() {
s.Write("hello")
s.Done()
s.Wait()
})
})
t.Run("very large write does not panic", func(t *testing.T) {
var buf bytes.Buffer
s := NewStream(WithStreamOutput(&buf))
large := strings.Repeat("x", 100_000)
assert.NotPanics(t, func() {
s.Write(large)
s.Done()
s.Wait()
})
assert.Equal(t, 100_000, len(strings.TrimRight(buf.String(), "\n")))
})
}

View file

@ -2,47 +2,71 @@ package cli
import "fmt"
// Sprintf formats a string (fmt.Sprintf wrapper).
// Sprintf formats a string using a format template.
//
// msg := cli.Sprintf("Hello, %s! You have %d messages.", name, count)
func Sprintf(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}
// Sprint formats using default formats (fmt.Sprint wrapper).
// Sprint formats using default formats without a format string.
//
// label := cli.Sprint("count:", count)
func Sprint(args ...any) string {
return fmt.Sprint(args...)
}
// Styled returns text with a style applied.
//
// label := cli.Styled(cli.AccentStyle, "core dev")
func Styled(style *AnsiStyle, text string) string {
return style.Render(text)
if style == nil {
return compileGlyphs(text)
}
return style.Render(compileGlyphs(text))
}
// Styledf returns formatted text with a style applied.
//
// header := cli.Styledf(cli.HeaderStyle, "%s v%s", name, version)
func Styledf(style *AnsiStyle, format string, args ...any) string {
return style.Render(fmt.Sprintf(format, args...))
if style == nil {
return compileGlyphs(fmt.Sprintf(format, args...))
}
return style.Render(compileGlyphs(fmt.Sprintf(format, args...)))
}
// SuccessStr returns success-styled string.
// SuccessStr returns a success-styled string without printing it.
//
// line := cli.SuccessStr("all tests passed")
func SuccessStr(msg string) string {
return SuccessStyle.Render(Glyph(":check:") + " " + msg)
return SuccessStyle.Render(Glyph(":check:") + " " + compileGlyphs(msg))
}
// ErrorStr returns error-styled string.
// ErrorStr returns an error-styled string without printing it.
//
// line := cli.ErrorStr("connection refused")
func ErrorStr(msg string) string {
return ErrorStyle.Render(Glyph(":cross:") + " " + msg)
return ErrorStyle.Render(Glyph(":cross:") + " " + compileGlyphs(msg))
}
// WarnStr returns warning-styled string.
// WarnStr returns a warning-styled string without printing it.
//
// line := cli.WarnStr("deprecated flag")
func WarnStr(msg string) string {
return WarningStyle.Render(Glyph(":warn:") + " " + msg)
return WarningStyle.Render(Glyph(":warn:") + " " + compileGlyphs(msg))
}
// InfoStr returns info-styled string.
// InfoStr returns an info-styled string without printing it.
//
// line := cli.InfoStr("listening on :8080")
func InfoStr(msg string) string {
return InfoStyle.Render(Glyph(":info:") + " " + msg)
return InfoStyle.Render(Glyph(":info:") + " " + compileGlyphs(msg))
}
// DimStr returns dim-styled string.
// DimStr returns a dim-styled string without printing it.
//
// line := cli.DimStr("optional: use --verbose for details")
func DimStr(msg string) string {
return DimStyle.Render(msg)
return DimStyle.Render(compileGlyphs(msg))
}

68
pkg/cli/strings_test.go Normal file
View file

@ -0,0 +1,68 @@
package cli
import (
"strings"
"testing"
)
func TestStrings_Good(t *testing.T) {
// Sprintf formats correctly.
result := Sprintf("Hello, %s! Count: %d", "world", 42)
if result != "Hello, world! Count: 42" {
t.Errorf("Sprintf: got %q", result)
}
// Sprint joins with spaces.
result = Sprint("foo", "bar")
if result == "" {
t.Error("Sprint: got empty string")
}
// SuccessStr, ErrorStr, WarnStr, InfoStr, DimStr return non-empty strings.
if SuccessStr("done") == "" {
t.Error("SuccessStr: got empty string")
}
if ErrorStr("fail") == "" {
t.Error("ErrorStr: got empty string")
}
if WarnStr("warn") == "" {
t.Error("WarnStr: got empty string")
}
if InfoStr("info") == "" {
t.Error("InfoStr: got empty string")
}
if DimStr("dim") == "" {
t.Error("DimStr: got empty string")
}
}
func TestStrings_Bad(t *testing.T) {
// Sprintf with no args returns the format string unchanged.
result := Sprintf("no args here")
if result != "no args here" {
t.Errorf("Sprintf no-args: got %q", result)
}
// Styled with nil style should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("Styled with nil style panicked: %v", r)
}
}()
Styled(nil, "text")
}
func TestStrings_Ugly(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
// Without colour, styled strings contain the raw text.
result := Styled(NewStyle().Bold(), "core")
if !strings.Contains(result, "core") {
t.Errorf("Styled: expected 'core' in result, got %q", result)
}
// Styledf with empty format.
result = Styledf(DimStyle, "")
_ = result // should not panic
}

View file

@ -5,6 +5,9 @@ import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
)
// Tailwind colour palette (hex strings)
@ -69,21 +72,53 @@ var (
// Truncate shortens a string to max length with ellipsis.
func Truncate(s string, max int) string {
if len(s) <= max {
if max <= 0 || s == "" {
return ""
}
if displayWidth(s) <= max {
return s
}
if max <= 3 {
return s[:max]
return truncateByWidth(s, max)
}
return s[:max-3] + "..."
return truncateByWidth(s, max-3) + "..."
}
// Pad right-pads a string to width.
func Pad(s string, width int) string {
if len(s) >= width {
if displayWidth(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
return s + strings.Repeat(" ", width-displayWidth(s))
}
func displayWidth(s string) int {
return runewidth.StringWidth(ansi.Strip(s))
}
func truncateByWidth(s string, max int) string {
if max <= 0 || s == "" {
return ""
}
plain := ansi.Strip(s)
if displayWidth(plain) <= max {
return plain
}
var (
width int
out strings.Builder
)
for _, r := range plain {
rw := runewidth.RuneWidth(r)
if width+rw > max {
break
}
out.WriteRune(r)
width += rw
}
return out.String()
}
// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago").
@ -139,6 +174,13 @@ var borderSets = map[BorderStyle]borderSet{
BorderDouble: {"╔", "╗", "╚", "╝", "═", "║", "╦", "╩", "╠", "╣", "╬"},
}
var borderSetsASCII = map[BorderStyle]borderSet{
BorderNormal: {"+", "+", "+", "+", "-", "|", "+", "+", "+", "+", "+"},
BorderRounded: {"+", "+", "+", "+", "-", "|", "+", "+", "+", "+", "+"},
BorderHeavy: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"},
BorderDouble: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"},
}
// CellStyleFn returns a style based on the cell's raw value.
// Return nil to use the table's default CellStyle.
type CellStyleFn func(value string) *AnsiStyle
@ -233,7 +275,7 @@ func (t *Table) String() string {
// Render prints the table to stdout.
func (t *Table) Render() {
fmt.Print(t.String())
fmt.Fprint(stdoutWriter(), t.String())
}
func (t *Table) colCount() int {
@ -249,14 +291,16 @@ func (t *Table) columnWidths() []int {
widths := make([]int, cols)
for i, h := range t.Headers {
if len(h) > widths[i] {
widths[i] = len(h)
if w := displayWidth(compileGlyphs(h)); w > widths[i] {
widths[i] = w
}
}
for _, row := range t.Rows {
for i, cell := range row {
if i < cols && len(cell) > widths[i] {
widths[i] = len(cell)
if i < cols {
if w := displayWidth(compileGlyphs(cell)); w > widths[i] {
widths[i] = w
}
}
}
}
@ -323,7 +367,7 @@ func (t *Table) renderPlain() string {
if i > 0 {
sb.WriteString(sep)
}
cell := Pad(Truncate(h, widths[i]), widths[i])
cell := Pad(Truncate(compileGlyphs(h), widths[i]), widths[i])
if t.Style.HeaderStyle != nil {
cell = t.Style.HeaderStyle.Render(cell)
}
@ -341,7 +385,7 @@ func (t *Table) renderPlain() string {
if i < len(row) {
val = row[i]
}
cell := Pad(Truncate(val, widths[i]), widths[i])
cell := Pad(Truncate(compileGlyphs(val), widths[i]), widths[i])
if style := t.resolveStyle(i, val); style != nil {
cell = style.Render(cell)
}
@ -354,7 +398,7 @@ func (t *Table) renderPlain() string {
}
func (t *Table) renderBordered() string {
b := borderSets[t.borders]
b := tableBorderSet(t.borders)
widths := t.columnWidths()
cols := t.colCount()
@ -379,7 +423,7 @@ func (t *Table) renderBordered() string {
if i < len(t.Headers) {
h = t.Headers[i]
}
cell := Pad(Truncate(h, widths[i]), widths[i])
cell := Pad(Truncate(compileGlyphs(h), widths[i]), widths[i])
if t.Style.HeaderStyle != nil {
cell = t.Style.HeaderStyle.Render(cell)
}
@ -410,7 +454,7 @@ func (t *Table) renderBordered() string {
if i < len(row) {
val = row[i]
}
cell := Pad(Truncate(val, widths[i]), widths[i])
cell := Pad(Truncate(compileGlyphs(val), widths[i]), widths[i])
if style := t.resolveStyle(i, val); style != nil {
cell = style.Render(cell)
}
@ -435,3 +479,15 @@ func (t *Table) renderBordered() string {
return sb.String()
}
func tableBorderSet(style BorderStyle) borderSet {
if currentTheme == ThemeASCII {
if b, ok := borderSetsASCII[style]; ok {
return b
}
}
if b, ok := borderSets[style]; ok {
return b
}
return borderSet{}
}

View file

@ -81,6 +81,22 @@ func TestTable_Good(t *testing.T) {
assert.Contains(t, out, "║")
})
t.Run("ASCII theme uses ASCII borders", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tbl := NewTable("REPO", "STATUS").WithBorders(BorderRounded)
tbl.AddRow("core", "clean")
out := tbl.String()
assert.Contains(t, out, "+")
assert.Contains(t, out, "-")
assert.Contains(t, out, "|")
assert.NotContains(t, out, "╭")
assert.NotContains(t, out, "╮")
assert.NotContains(t, out, "│")
})
t.Run("bordered structure", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
@ -130,6 +146,19 @@ func TestTable_Good(t *testing.T) {
assert.Contains(t, out, "ok")
})
t.Run("glyph shortcodes render in headers and cells", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tbl := NewTable(":check: NAME", "STATUS").
WithBorders(BorderRounded)
tbl.AddRow("core", ":warn:")
out := tbl.String()
assert.Contains(t, out, "[OK] NAME")
assert.Contains(t, out, "[WARN]")
})
t.Run("max width truncates", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
@ -194,13 +223,81 @@ func TestTable_Bad(t *testing.T) {
})
}
func TestTable_Ugly(t *testing.T) {
t.Run("no columns no panic", func(t *testing.T) {
assert.NotPanics(t, func() {
tbl := NewTable()
tbl.AddRow()
_ = tbl.String()
})
})
t.Run("cell style function returning nil does not panic", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
tbl := NewTable("A").WithCellStyle(0, func(_ string) *AnsiStyle {
return nil
})
tbl.AddRow("value")
assert.NotPanics(t, func() {
_ = tbl.String()
})
})
t.Run("max width of 1 does not panic", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
tbl := NewTable("HEADER").WithMaxWidth(1)
tbl.AddRow("data")
assert.NotPanics(t, func() {
_ = tbl.String()
})
})
}
func TestTruncate_Good(t *testing.T) {
assert.Equal(t, "hel...", Truncate("hello world", 6))
assert.Equal(t, "hi", Truncate("hi", 6))
assert.Equal(t, "he", Truncate("hello", 2))
assert.Equal(t, "東", Truncate("東京", 3))
}
func TestTruncate_Ugly(t *testing.T) {
t.Run("zero max does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
_ = Truncate("hello", 0)
})
})
}
func TestPad_Good(t *testing.T) {
assert.Equal(t, "hi ", Pad("hi", 5))
assert.Equal(t, "hello", Pad("hello", 3))
assert.Equal(t, "東京 ", Pad("東京", 6))
}
func TestStyled_Good_NilStyle(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
assert.Equal(t, "hello [OK]", Styled(nil, "hello :check:"))
}
func TestStyledf_Good_NilStyle(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
assert.Equal(t, "value: [WARN]", Styledf(nil, "value: %s", ":warn:"))
}
func TestPad_Ugly(t *testing.T) {
t.Run("zero width does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
_ = Pad("hello", 0)
})
})
}

View file

@ -12,8 +12,9 @@ import (
"golang.org/x/term"
)
// Spinner frames (braille pattern).
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// Spinner frames for the live tracker.
var spinnerFramesUnicode = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
var spinnerFramesASCII = []string{"-", "\\", "|", "/"}
// taskState tracks the lifecycle of a tracked task.
type taskState int
@ -88,8 +89,11 @@ type TaskTracker struct {
func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
return func(yield func(*TrackedTask) bool) {
tr.mu.Lock()
defer tr.mu.Unlock()
for _, t := range tr.tasks {
tasks := make([]*TrackedTask, len(tr.tasks))
copy(tasks, tr.tasks)
tr.mu.Unlock()
for _, t := range tasks {
if !yield(t) {
return
}
@ -101,8 +105,11 @@ func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
return func(yield func(string, string) bool) {
tr.mu.Lock()
defer tr.mu.Unlock()
for _, t := range tr.tasks {
tasks := make([]*TrackedTask, len(tr.tasks))
copy(tasks, tr.tasks)
tr.mu.Unlock()
for _, t := range tasks {
name, status, _ := t.snapshot()
if !yield(name, status) {
return
@ -113,7 +120,16 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
// NewTaskTracker creates a new parallel task tracker.
func NewTaskTracker() *TaskTracker {
return &TaskTracker{out: os.Stdout}
return &TaskTracker{out: stderrWriter()}
}
// WithOutput sets the destination writer for tracker output.
// Pass nil to keep the current writer unchanged.
func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker {
if out != nil {
tr.out = out
}
return tr
}
// Add registers a task and returns it for goroutine use.
@ -159,6 +175,8 @@ func (tr *TaskTracker) waitStatic() {
allDone := true
for i, t := range tasks {
name, status, state := t.snapshot()
name = compileGlyphs(name)
status = compileGlyphs(status)
if state != taskDone && state != taskFailed {
allDone = false
continue
@ -190,6 +208,9 @@ func (tr *TaskTracker) waitLive() {
for i := range n {
tr.renderLine(i, frame)
}
if n == 0 || tr.allDone() {
return
}
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
@ -220,6 +241,8 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
tr.mu.Unlock()
name, status, state := t.snapshot()
name = compileGlyphs(name)
status = compileGlyphs(status)
nameW := tr.nameWidth()
var icon string
@ -227,7 +250,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
case taskPending:
icon = DimStyle.Render(Glyph(":pending:"))
case taskRunning:
icon = InfoStyle.Render(spinnerFrames[frame%len(spinnerFrames)])
icon = InfoStyle.Render(trackerSpinnerFrame(frame))
case taskDone:
icon = SuccessStyle.Render(Glyph(":check:"))
case taskFailed:
@ -244,7 +267,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
styledStatus = DimStyle.Render(status)
}
fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus)
fmt.Fprintf(tr.out, "\033[2K%s %s %s\n", icon, Pad(name, nameW), styledStatus)
}
func (tr *TaskTracker) nameWidth() int {
@ -252,8 +275,8 @@ func (tr *TaskTracker) nameWidth() int {
defer tr.mu.Unlock()
w := 0
for _, t := range tr.tasks {
if len(t.name) > w {
w = len(t.name)
if nameW := displayWidth(compileGlyphs(t.name)); nameW > w {
w = nameW
}
}
return w
@ -304,16 +327,26 @@ func (tr *TaskTracker) String() string {
var sb strings.Builder
for _, t := range tasks {
name, status, state := t.snapshot()
icon := "…"
name = compileGlyphs(name)
status = compileGlyphs(status)
icon := Glyph(":pending:")
switch state {
case taskDone:
icon = "✓"
icon = Glyph(":check:")
case taskFailed:
icon = "✗"
icon = Glyph(":cross:")
case taskRunning:
icon = "⠋"
icon = Glyph(":spinner:")
}
fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status)
fmt.Fprintf(&sb, "%s %s %s\n", icon, Pad(name, nameW), status)
}
return sb.String()
}
func trackerSpinnerFrame(frame int) string {
frames := spinnerFramesUnicode
if currentTheme == ThemeASCII {
frames = spinnerFramesASCII
}
return frames[frame%len(frames)]
}

View file

@ -10,6 +10,17 @@ import (
"github.com/stretchr/testify/require"
)
func restoreThemeAndColors(t *testing.T) {
t.Helper()
prevTheme := currentTheme
prevColor := ColorEnabled()
t.Cleanup(func() {
currentTheme = prevTheme
SetColorEnabled(prevColor)
})
}
func TestTaskTracker_Good(t *testing.T) {
t.Run("add and complete tasks", func(t *testing.T) {
tr := NewTaskTracker()
@ -110,8 +121,7 @@ func TestTaskTracker_Good(t *testing.T) {
t.Run("wait completes for non-TTY", func(t *testing.T) {
var buf bytes.Buffer
tr := NewTaskTracker()
tr.out = &buf
tr := NewTaskTracker().WithOutput(&buf)
task := tr.Add("quick")
go func() {
@ -124,6 +134,17 @@ func TestTaskTracker_Good(t *testing.T) {
assert.Contains(t, buf.String(), "done")
})
t.Run("WithOutput sets output writer", func(t *testing.T) {
var buf bytes.Buffer
tr := NewTaskTracker().WithOutput(&buf)
tr.Add("quick").Done("done")
tr.Wait()
assert.Contains(t, buf.String(), "quick")
assert.Contains(t, buf.String(), "done")
})
t.Run("name width alignment", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
@ -135,6 +156,17 @@ func TestTaskTracker_Good(t *testing.T) {
assert.Equal(t, 19, w)
})
t.Run("name width counts visible width", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add("東京")
tr.Add("repo")
w := tr.nameWidth()
assert.Equal(t, 4, w)
})
t.Run("String output format", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
@ -148,6 +180,68 @@ func TestTaskTracker_Good(t *testing.T) {
assert.Contains(t, out, "✗")
assert.Contains(t, out, "⠋")
})
t.Run("glyph shortcodes render in names and statuses", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add(":check: repo").Done("done :warn:")
out := tr.String()
assert.Contains(t, out, "[OK] repo")
assert.Contains(t, out, "[WARN]")
})
t.Run("ASCII theme uses ASCII symbols", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add("repo-a").Done("clean")
tr.Add("repo-b").Fail("dirty")
tr.Add("repo-c").Update("pulling")
out := tr.String()
assert.Contains(t, out, "[OK]")
assert.Contains(t, out, "[FAIL]")
assert.Contains(t, out, "-")
assert.NotContains(t, out, "✓")
assert.NotContains(t, out, "✗")
})
t.Run("iterators tolerate mutation during iteration", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add("first")
tr.Add("second")
done := make(chan struct{})
go func() {
defer close(done)
for task := range tr.Tasks() {
task.Update("visited")
}
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond)
for name, status := range tr.Snapshots() {
assert.Equal(t, "visited", status, name)
}
})
}
func TestTaskTracker_Bad(t *testing.T) {
@ -186,3 +280,46 @@ func TestTrackedTask_Good(t *testing.T) {
require.Equal(t, "running", status)
})
}
func TestTaskTracker_Ugly(t *testing.T) {
t.Run("empty task name does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
assert.NotPanics(t, func() {
task := tr.Add("")
task.Done("ok")
})
})
t.Run("Done called twice does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
task := tr.Add("double-done")
assert.NotPanics(t, func() {
task.Done("first")
task.Done("second")
})
})
t.Run("Fail after Done does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
task := tr.Add("already-done")
assert.NotPanics(t, func() {
task.Done("completed")
task.Fail("too late")
})
})
t.Run("String on empty tracker does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
assert.NotPanics(t, func() {
_ = tr.String()
})
})
}

View file

@ -79,24 +79,29 @@ func (n *TreeNode) String() string {
// Render prints the tree to stdout.
func (n *TreeNode) Render() {
fmt.Print(n.String())
fmt.Fprint(stdoutWriter(), n.String())
}
func (n *TreeNode) renderLabel() string {
label := compileGlyphs(n.label)
if n.style != nil {
return n.style.Render(n.label)
return n.style.Render(label)
}
return n.label
return label
}
func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) {
tee := Glyph(":tee:") + Glyph(":dash:") + Glyph(":dash:") + " "
corner := Glyph(":corner:") + Glyph(":dash:") + Glyph(":dash:") + " "
pipe := Glyph(":pipe:") + " "
for i, child := range n.children {
last := i == len(n.children)-1
connector := "├── "
next := "│ "
connector := tee
next := pipe
if last {
connector = "└── "
connector = corner
next = " "
}

View file

@ -103,6 +103,40 @@ func TestTree_Good(t *testing.T) {
"└── child\n"
assert.Equal(t, expected, tree.String())
})
t.Run("ASCII theme uses ASCII connectors", func(t *testing.T) {
prevTheme := currentTheme
prevColor := ColorEnabled()
UseASCII()
t.Cleanup(func() {
currentTheme = prevTheme
SetColorEnabled(prevColor)
})
tree := NewTree("core-php")
tree.Add("core-tenant").Add("core-bio")
tree.Add("core-admin")
tree.Add("core-api")
expected := "core-php\n" +
"+-- core-tenant\n" +
"| `-- core-bio\n" +
"+-- core-admin\n" +
"`-- core-api\n"
assert.Equal(t, expected, tree.String())
})
t.Run("glyph shortcodes render in labels", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tree := NewTree(":check: root")
tree.Add(":warn: child")
out := tree.String()
assert.Contains(t, out, "[OK] root")
assert.Contains(t, out, "[WARN] child")
})
}
func TestTree_Bad(t *testing.T) {
@ -111,3 +145,31 @@ func TestTree_Bad(t *testing.T) {
assert.Equal(t, "\n", tree.String())
})
}
func TestTree_Ugly(t *testing.T) {
t.Run("nil style does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
tree := NewTree("root").WithStyle(nil)
tree.Add("child")
_ = tree.String()
})
})
t.Run("AddStyled with nil style does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
tree := NewTree("root")
tree.AddStyled("item", nil)
_ = tree.String()
})
})
t.Run("very deep nesting does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
node := NewTree("root")
for range 100 {
node = node.Add("child")
}
_ = NewTree("root").String()
})
})
}

View file

@ -1,14 +1,13 @@
package cli
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"unicode"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log"
@ -31,6 +30,10 @@ func GhAuthenticated() bool {
}
// ConfirmOption configures Confirm behaviour.
//
// if cli.Confirm("Proceed?", cli.DefaultYes()) {
// cli.Success("continuing")
// }
type ConfirmOption func(*confirmConfig)
type confirmConfig struct {
@ -39,6 +42,14 @@ type confirmConfig struct {
timeout time.Duration
}
func promptHint(msg string) {
fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg)))
}
func promptWarning(msg string) {
fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg)))
}
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
func DefaultYes() ConfirmOption {
return func(c *confirmConfig) {
@ -82,6 +93,8 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
opt(cfg)
}
prompt = compileGlyphs(prompt)
// Build the prompt suffix
var suffix string
if cfg.required {
@ -97,37 +110,50 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
}
reader := bufio.NewReader(os.Stdin)
reader := newReader()
for {
fmt.Printf("%s %s", prompt, suffix)
fmt.Fprintf(stderrWriter(), "%s %s", prompt, suffix)
var response string
var readErr error
if cfg.timeout > 0 {
// Use timeout-based reading
resultChan := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
line, _ := reader.ReadString('\n')
line, err := reader.ReadString('\n')
resultChan <- line
errChan <- err
}()
select {
case response = <-resultChan:
readErr = <-errChan
response = strings.ToLower(strings.TrimSpace(response))
case <-time.After(cfg.timeout):
fmt.Println() // New line after timeout
fmt.Fprintln(stderrWriter()) // New line after timeout
return cfg.defaultYes
}
} else {
response, _ = reader.ReadString('\n')
line, err := reader.ReadString('\n')
readErr = err
if err != nil && line == "" {
return cfg.defaultYes
}
response = line
response = strings.ToLower(strings.TrimSpace(response))
}
// Handle empty response
if response == "" {
if readErr == nil && cfg.required {
promptHint("Please enter y or n, then press Enter.")
continue
}
if cfg.required {
continue // Ask again
return cfg.defaultYes
}
return cfg.defaultYes
}
@ -142,7 +168,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
// Invalid response
if cfg.required {
fmt.Println("Please enter 'y' or 'n'")
promptHint("Please enter y or n, then press Enter.")
continue
}
@ -175,6 +201,8 @@ func ConfirmDangerousAction(verb, subject string) bool {
}
// QuestionOption configures Question behaviour.
//
// name := cli.Question("Project name:", cli.WithDefault("my-app"))
type QuestionOption func(*questionConfig)
type questionConfig struct {
@ -215,23 +243,28 @@ func Question(prompt string, opts ...QuestionOption) string {
opt(cfg)
}
reader := bufio.NewReader(os.Stdin)
prompt = compileGlyphs(prompt)
reader := newReader()
for {
// Build prompt with default
if cfg.defaultValue != "" {
fmt.Printf("%s [%s] ", prompt, cfg.defaultValue)
fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
} else {
fmt.Printf("%s ", prompt)
fmt.Fprintf(stderrWriter(), "%s ", prompt)
}
response, _ := reader.ReadString('\n')
response, err := reader.ReadString('\n')
response = strings.TrimSpace(response)
if err != nil && response == "" {
return cfg.defaultValue
}
// Handle empty response
if response == "" {
if cfg.required {
fmt.Println("Response required")
promptHint("Please enter a value, then press Enter.")
continue
}
response = cfg.defaultValue
@ -240,7 +273,7 @@ func Question(prompt string, opts ...QuestionOption) string {
// Validate if validator provided
if cfg.validator != nil {
if err := cfg.validator(response); err != nil {
fmt.Printf("Invalid: %v\n", err)
promptWarning(fmt.Sprintf("Invalid: %v", err))
continue
}
}
@ -258,12 +291,16 @@ func QuestionAction(verb, subject string, opts ...QuestionOption) string {
}
// ChooseOption configures Choose behaviour.
//
// choice := cli.Choose("Pick one:", items, cli.Display(func(v Item) string {
// return v.Name
// }))
type ChooseOption[T any] func(*chooseConfig[T])
type chooseConfig[T any] struct {
displayFn func(T) string
defaultN int // 0-based index of default selection
filter bool // Enable fuzzy filtering
filter bool // Enable type-to-filter selection
multi bool // Allow multiple selection
}
@ -282,9 +319,7 @@ func WithDefaultIndex[T any](idx int) ChooseOption[T] {
}
// Filter enables type-to-filter functionality.
// Users can type to narrow down the list of options.
// Note: This is a hint for interactive UIs; the basic CLI Choose
// implementation uses numbered selection which doesn't support filtering.
// When enabled, typed text narrows the visible options before selection.
func Filter[T any]() ChooseOption[T] {
return func(c *chooseConfig[T]) {
c.filter = true
@ -320,42 +355,77 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) },
defaultN: -1,
}
for _, opt := range opts {
opt(cfg)
}
// Display options
fmt.Println(prompt)
for i, item := range items {
marker := " "
if i == cfg.defaultN {
marker = "*"
}
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item))
}
prompt = compileGlyphs(prompt)
reader := bufio.NewReader(os.Stdin)
reader := newReader()
visible := make([]int, len(items))
for i := range items {
visible[i] = i
}
allVisible := append([]int(nil), visible...)
for {
fmt.Printf("Enter number [1-%d]: ", len(items))
response, _ := reader.ReadString('\n')
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
if cfg.filter {
fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible))
} else {
fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible))
}
response, err := reader.ReadString('\n')
response = strings.TrimSpace(response)
// Empty response uses default
if response == "" {
return items[cfg.defaultN]
if err != nil && response == "" {
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
return items[idx]
}
var zero T
return zero
}
if response == "" {
if cfg.filter && len(visible) != len(allVisible) {
visible = append([]int(nil), allVisible...)
promptHint("Filter cleared.")
continue
}
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
return items[idx]
}
if cfg.defaultN >= 0 {
promptHint("Default selection is not available in the current list. Narrow the list or choose another number.")
continue
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
continue
}
// Parse number
var n int
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
if n >= 1 && n <= len(items) {
return items[n-1]
if n >= 1 && n <= len(visible) {
return items[visible[n-1]]
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
continue
}
fmt.Printf("Please enter a number between 1 and %d\n", len(items))
if cfg.filter {
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
if len(nextVisible) == 0 {
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
continue
}
visible = nextVisible
continue
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
}
}
@ -385,51 +455,126 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) },
defaultN: -1,
}
for _, opt := range opts {
opt(cfg)
}
// Display options
fmt.Println(prompt)
for i, item := range items {
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item))
prompt = compileGlyphs(prompt)
reader := newReader()
visible := make([]int, len(items))
for i := range items {
visible[i] = i
}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
if cfg.filter {
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
} else {
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
}
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)
// Empty response returns no selections
// Empty response returns no selections.
if response == "" {
return nil
}
// Parse the selection
selected, err := parseMultiSelection(response, len(items))
// Parse the selection.
selected, err := parseMultiSelection(response, len(visible))
if err != nil {
fmt.Printf("Invalid selection: %v\n", err)
if cfg.filter && !looksLikeMultiSelectionInput(response) {
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
if len(nextVisible) == 0 {
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
continue
}
visible = nextVisible
continue
}
promptWarning(fmt.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response))
continue
}
// Build result
result := make([]T, 0, len(selected))
for _, idx := range selected {
result = append(result, items[idx])
result = append(result, items[visible[idx]])
}
return result
}
}
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
fmt.Fprintln(stderrWriter(), prompt)
for i, idx := range visible {
marker := " "
if defaultN >= 0 && idx == defaultN {
marker = "*"
}
fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
}
if filter {
fmt.Fprintln(stderrWriter(), " (type to filter the list)")
}
}
func defaultVisibleIndex(visible []int, defaultN int) (int, bool) {
if defaultN < 0 {
return 0, false
}
for _, idx := range visible {
if idx == defaultN {
return idx, true
}
}
return 0, false
}
func filterVisible[T any](items []T, visible []int, query string, displayFn func(T) string) []int {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return visible
}
filtered := make([]int, 0, len(visible))
for _, idx := range visible {
if strings.Contains(strings.ToLower(displayFn(items[idx])), q) {
filtered = append(filtered, idx)
}
}
return filtered
}
func looksLikeMultiSelectionInput(input string) bool {
hasDigit := false
for _, r := range input {
switch {
case unicode.IsSpace(r), r == '-' || r == ',':
continue
case unicode.IsDigit(r):
hasDigit = true
default:
return false
}
}
return hasDigit
}
// parseMultiSelection parses a multi-selection string like "1 3 5", "1,3,5",
// or "1-3 5".
// Returns 0-based indices.
func parseMultiSelection(input string, maxItems int) ([]int, error) {
selected := make(map[int]bool)
for part := range strings.FieldsSeq(input) {
normalized := strings.NewReplacer(",", " ").Replace(input)
for part := range strings.FieldsSeq(normalized) {
// Check for range (e.g., "1-3")
if strings.Contains(part, "-") {
var rangeParts []string
@ -437,17 +582,17 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
rangeParts = append(rangeParts, p)
}
if len(rangeParts) != 2 {
return nil, fmt.Errorf("invalid range: %s", part)
return nil, Err("invalid range: %s", part)
}
var start, end int
if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil {
return nil, fmt.Errorf("invalid range start: %s", rangeParts[0])
return nil, Err("invalid range start: %s", rangeParts[0])
}
if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil {
return nil, fmt.Errorf("invalid range end: %s", rangeParts[1])
return nil, Err("invalid range end: %s", rangeParts[1])
}
if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end {
return nil, fmt.Errorf("range out of bounds: %s", part)
return nil, Err("range out of bounds: %s", part)
}
for i := start; i <= end; i++ {
selected[i-1] = true // Convert to 0-based
@ -456,10 +601,10 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
// Single number
var n int
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
return nil, fmt.Errorf("invalid number: %s", part)
return nil, Err("invalid number: %s", part)
}
if n < 1 || n > maxItems {
return nil, fmt.Errorf("number out of range: %d", n)
return nil, Err("number out of range: %d", n)
}
selected[n-1] = true // Convert to 0-based
}
@ -486,9 +631,19 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt
// GitClone clones a GitHub repository to the specified path.
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
func GitClone(ctx context.Context, org, repo, path string) error {
return GitCloneRef(ctx, org, repo, path, "")
}
// GitCloneRef clones a GitHub repository at a specific ref to the specified path.
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
if GhAuthenticated() {
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
args := []string{"repo", "clone", httpsURL, path}
if ref != "" {
args = append(args, "--", "--branch", ref, "--single-branch")
}
cmd := exec.CommandContext(ctx, "gh", args...)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
@ -499,7 +654,12 @@ func GitClone(ctx context.Context, org, repo, path string) error {
}
}
// Fall back to SSH clone
cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
args := []string{"clone"}
if ref != "" {
args = append(args, "--branch", ref, "--single-branch")
}
args = append(args, fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
cmd := exec.CommandContext(ctx, "git", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return errors.New(strings.TrimSpace(string(output)))

88
pkg/cli/utils_test.go Normal file
View file

@ -0,0 +1,88 @@
package cli
import (
"strings"
"testing"
)
func TestParseMultiSelection_Good(t *testing.T) {
// Single numbers.
result, err := parseMultiSelection("1 3 5", 5)
if err != nil {
t.Fatalf("parseMultiSelection: unexpected error: %v", err)
}
if len(result) != 3 {
t.Errorf("parseMultiSelection: expected 3 results, got %d: %v", len(result), result)
}
// Range notation.
result, err = parseMultiSelection("1-3", 5)
if err != nil {
t.Fatalf("parseMultiSelection range: unexpected error: %v", err)
}
if len(result) != 3 {
t.Errorf("parseMultiSelection range: expected 3 results, got %d: %v", len(result), result)
}
}
func TestParseMultiSelection_Bad(t *testing.T) {
// Out of range number.
_, err := parseMultiSelection("10", 5)
if err == nil {
t.Error("parseMultiSelection: expected error for out-of-range number")
}
// Invalid range format.
_, err = parseMultiSelection("1-2-3", 5)
if err == nil {
t.Error("parseMultiSelection: expected error for invalid range '1-2-3'")
}
// Non-numeric input.
_, err = parseMultiSelection("abc", 5)
if err == nil {
t.Error("parseMultiSelection: expected error for non-numeric input")
}
}
func TestParseMultiSelection_Ugly(t *testing.T) {
// Empty input returns empty slice.
result, err := parseMultiSelection("", 5)
if err != nil {
t.Fatalf("parseMultiSelection empty: unexpected error: %v", err)
}
if len(result) != 0 {
t.Errorf("parseMultiSelection empty: expected 0 results, got %d", len(result))
}
// Choose with empty items returns zero value.
choice := Choose("Select:", []string{})
if choice != "" {
t.Errorf("Choose empty: expected empty string, got %q", choice)
}
}
func TestMatchGlobInSearch_Good(t *testing.T) {
// matchGlob is in cmd_search.go — test parseMultiSelection indirectly here.
// Verify ChooseMulti with empty items returns nil without panicking.
result := ChooseMulti("Select:", []string{})
if result != nil {
t.Errorf("ChooseMulti empty: expected nil, got %v", result)
}
}
func TestGhAuthenticated_Bad(t *testing.T) {
// GhAuthenticated requires gh CLI — should not panic even if gh is unavailable.
defer func() {
if r := recover(); r != nil {
t.Errorf("GhAuthenticated panicked: %v", r)
}
}()
// We don't assert the return value since it depends on the environment.
_ = GhAuthenticated()
}
func TestGhAuthenticated_Ugly(t *testing.T) {
// GitClone with a non-existent path should return an error without panicking.
_ = strings.Contains // ensure strings is importable in this package context
}