Compare commits

..

121 commits

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
54 changed files with 2796 additions and 288 deletions

View file

@ -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"),

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

@ -160,9 +160,13 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
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/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=

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

@ -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

@ -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

@ -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

@ -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

@ -42,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.

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

14
go.mod
View file

@ -1,25 +1,26 @@
module forge.lthn.ai/core/cli
module dappco.re/go/core/cli
go 1.26.0
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 v0.3.2 // indirect
forge.lthn.ai/core/go-inference v0.1.7 // 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
@ -30,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

4
go.sum
View file

@ -1,7 +1,7 @@
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.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
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.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=

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,7 +86,33 @@ 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" {
@ -97,6 +121,7 @@ func TestRender_NilStyle_Good(t *testing.T) {
}
func TestAnsiStyle_Bad(t *testing.T) {
restoreThemeAndColors(t)
original := ColorEnabled()
defer SetColorEnabled(original)
@ -117,6 +142,7 @@ func TestAnsiStyle_Bad(t *testing.T) {
}
func TestAnsiStyle_Ugly(t *testing.T) {
restoreThemeAndColors(t)
original := ColorEnabled()
defer SetColorEnabled(original)

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"
"dappco.re/go/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 != "" {
@ -64,19 +71,37 @@ func WithAppName(name string) {
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.
//
// 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.
//
// Example:
// locales := []cli.LocaleSource{cli.WithLocales(embeddedLocales, "locales")}
// cli.MainWithLocales(locales, doctor.AddDoctorCommands)
func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
// Recovery from panics
defer func() {
@ -98,8 +123,8 @@ func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
// Initialise CLI runtime
if err := Init(Options{
AppName: AppName,
Version: SemVer(),
AppName: AppName,
Version: SemVer(),
I18nSources: extraFS,
}); err != nil {
Error(err.Error())
@ -175,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

@ -38,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
}
@ -64,23 +64,24 @@ 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 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 Sprintf(" %s %s %s", icon, c.name, status)
return Sprintf(" %s %s %s", icon, name, status)
}
return Sprintf(" %s %s", icon, c.name)
return Sprintf(" %s %s", icon, name)
}
// Print outputs the check result.

View file

@ -6,6 +6,7 @@ import (
)
func TestCheckBuilder_Good(t *testing.T) {
restoreThemeAndColors(t)
UseASCII() // Deterministic output
checkResult := Check("database").Pass()
@ -19,6 +20,7 @@ func TestCheckBuilder_Good(t *testing.T) {
}
func TestCheckBuilder_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
checkResult := Check("lint").Fail()
@ -41,6 +43,7 @@ func TestCheckBuilder_Bad(t *testing.T) {
}
func TestCheckBuilder_Ugly(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
// Zero-value builder should not panic.

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
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -7,6 +7,7 @@ import (
"sync"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
)
@ -19,6 +20,7 @@ import (
// )
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)
}
@ -27,6 +29,13 @@ func WithCommands(name string, register func(root *Command), localeFS ...fs.FS)
}
// CommandRegistration is a function that adds commands to the CLI 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 (
@ -42,6 +51,13 @@ 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()
registeredCommands = append(registeredCommands, fn)
@ -49,6 +65,7 @@ func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
root := instance
registeredCommandsMu.Unlock()
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
appendLocales(localeFS...)
// If commands already attached (CLI already running), attach immediately
@ -73,19 +90,62 @@ func appendLocales(localeFS ...fs.FS) {
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
}
@ -97,10 +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

@ -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
}

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

@ -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)
}

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

@ -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

@ -3,6 +3,7 @@ package cli
import "testing"
func TestGlyph_Good(t *testing.T) {
restoreThemeAndColors(t)
UseUnicode()
if Glyph(":check:") != "✓" {
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
@ -15,6 +16,7 @@ func TestGlyph_Good(t *testing.T) {
}
func TestGlyph_Bad(t *testing.T) {
restoreThemeAndColors(t)
// Unknown shortcode returns the shortcode unchanged.
UseUnicode()
got := Glyph(":unknown:")
@ -24,6 +26,7 @@ func TestGlyph_Bad(t *testing.T) {
}
func TestGlyph_Ugly(t *testing.T) {
restoreThemeAndColors(t)
// Empty shortcode should not panic.
got := Glyph("")
if got != "" {
@ -32,6 +35,7 @@ func TestGlyph_Ugly(t *testing.T) {
}
func TestCompileGlyphs_Good(t *testing.T) {
restoreThemeAndColors(t)
UseUnicode()
got := compileGlyphs("Status: :check:")
if got != "Status: ✓" {
@ -40,6 +44,7 @@ func TestCompileGlyphs_Good(t *testing.T) {
}
func TestCompileGlyphs_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseUnicode()
// Text with no shortcodes should be returned as-is.
got := compileGlyphs("no glyphs here")
@ -49,6 +54,7 @@ func TestCompileGlyphs_Bad(t *testing.T) {
}
func TestCompileGlyphs_Ugly(t *testing.T) {
restoreThemeAndColors(t)
// Empty string should not panic.
got := compileGlyphs("")
if got != "" {

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

@ -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"
)
@ -34,3 +36,15 @@ func LogWarn(msg string, keyvals ...any) { log.Warn(msg, keyvals...) }
//
// 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...))
}

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

@ -27,6 +27,7 @@ func captureOutput(f func()) string {
}
func TestSemanticOutput_Good(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
SetColorEnabled(false)
defer SetColorEnabled(true)
@ -52,6 +53,7 @@ func TestSemanticOutput_Good(t *testing.T) {
}
func TestSemanticOutput_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
SetColorEnabled(false)
defer SetColorEnabled(true)
@ -74,6 +76,7 @@ func TestSemanticOutput_Bad(t *testing.T) {
}
func TestSemanticOutput_Ugly(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
// Severity with various levels should not panic.

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

@ -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")
}
}

View file

@ -19,6 +19,7 @@ import (
"os/signal"
"sync"
"syscall"
"time"
"dappco.re/go/core"
"github.com/spf13/cobra"
@ -38,6 +39,12 @@ 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
@ -51,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() {
@ -110,6 +122,8 @@ func Init(opts Options) error {
return
}
loadLocaleSources(opts.I18nSources...)
// Attach registered commands AFTER Core startup so i18n is available
attachRegisteredCommands(rootCmd)
})
@ -138,25 +152,98 @@ 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 Srv (internal) ---

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")
}
}

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

@ -20,47 +20,53 @@ func Sprint(args ...any) string {
//
// 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 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 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 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 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 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))
}

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)
@ -234,6 +263,7 @@ 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) {
@ -247,6 +277,21 @@ func TestTruncate_Ugly(t *testing.T) {
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) {

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) {

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) {

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)))