Compare commits

..

143 commits
v0.3.2 ... dev

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

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

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

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 22:56:10 +00:00
Snider
92da6e8a73 refactor: migrate to dappco.re/go/core + Options{} API
Some checks failed
Deploy / build (push) Failing after 6s
Security Scan / security (push) Successful in 19s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 20:01:25 +00:00
Snider
542698c579 fix: update for CoreGO API — s.core.App → s.core.App().Runtime
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-18 12:19:03 +00:00
Snider
85eaceec05 chore: sync dependencies for v0.3.8
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:55:08 +00:00
Snider
9aaa0c0707 chore: sync dependencies for v0.3.7
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 23s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:50:52 +00:00
Snider
0c1b74c637 feat: auto-derive i18n keys from command names (Conclave pattern)
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 13s
commandService.applyI18n() walks registered commands and sets
Short/Long from cmd.{name}.short/long keys automatically. Downstream
packages no longer need to call i18n.T() for command descriptions —
the CLI Conclave handles it via service name derivation.

This is the Conclave pattern: services inside a sealed core.New()
auto-discover each other's capabilities via the lifecycle hooks.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 05:22:28 +00:00
Snider
91de96994a feat: re-enable go-build imports (SDK dep conflict fixed)
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 02:28:17 +00:00
Snider
5ebdc602d1 fix: defer doctor i18n.T() to AddDoctorCommands
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 02:22:04 +00:00
Snider
d67295ad2a fix: attach commands after Core startup, disable go-build SDK conflict
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 14s
Commands now attach AFTER i18n service starts — translations resolve.
go-build imports commented out until kin-openapi dep conflict is fixed.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 02:00:43 +00:00
Snider
7e7b83cd70 feat: feed RegisteredLocales into i18n ExtraFS
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 13s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 01:45:41 +00:00
Snider
ee7e9d1abf feat: RegisterCommands accepts locale FS for automatic translation loading
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 15s
Both WithCommands() and RegisterCommands() now accept an optional
fs.FS for translations. The CLI collects them via RegisteredLocales()
and the i18n service loads them on startup.

Packages just pass their embed.FS — no i18n import needed:
  cli.RegisterCommands(AddDevCommands, locales.FS)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 01:35:04 +00:00
Snider
bf994fab17 feat: embed CLI locale files, wire i18n ExtraFS
Some checks failed
Deploy / build (push) Failing after 7s
Security Scan / security (push) Successful in 20s
- Added locales/en.json with 90 translation keys for doctor, pkg commands
- Main() embeds CLI locales automatically
- MainWithLocales() accepts additional FSSource for consuming binaries
- Ecosystem packages can ship their own locale files

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 00:28:52 +00:00
Snider
77c7d19402 chore: sync dependencies for v0.3.6
Some checks failed
Deploy / build (push) Failing after 5s
Security Scan / security (push) Successful in 20s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 22:20:25 +00:00
Snider
e58d804779 chore: update go-i18n v0.1.5, go-crypt v0.1.8
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 12s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 19:23:41 +00:00
Snider
417c7cbbf4 fix(cmd/core): update ecosystem deps, fix cross-compilation
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 13s
go-devops v0.0.3→v0.1.9, go-build v0.2.0→v0.2.3, lint v0.3.0→v0.3.2.
Resolves stale core/go/pkg/* import paths. Binary cross-compiles
with GOWORK=off CGO_ENABLED=0.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 16:43:57 +00:00
Snider
1dd0cfb79d feat(cmd/core): restore ecosystem commands in binary sub-module
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 14s
Ecosystem imports (go-devops, go-build, go-scm, lint, go-crypt) added
back to cmd/core/main.go. Library go.mod stays clean (1 forge dep).
Binary gets full toolchain via workspace build.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 16:40:17 +00:00
Snider
2efcbd59ec refactor: move I18nService to go-i18n, simplify log wrapper
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 13s
I18nService now lives in go-i18n as NewCoreService() — any binary can
use it without importing cli. Log convenience functions use go-log
directly. Removed LogService/NewLogService/daemon_cmd wrappers.

Root go.mod: 1 direct forge dep (core/go).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 16:24:12 +00:00
Snider
55b556d1af refactor: split library from binary, remove ecosystem commands
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 13s
core/cli is now a pure library (pkg/cli). The binary moves to
cmd/core/ as a separate sub-module with its own go.mod.

Removed from binary: gocmd (→ lint/go-build), service (→ go-process),
session (→ go-session), module (→ go-scm), plugin (→ go-scm).
Removed from framework: go-crypt, workspace, daemon_cmd.

Root go.mod: 1 direct forge dep (core/go). Cross-compiles CGO_ENABLED=0.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 16:14:40 +00:00
Snider
7e2c7cd2f6 fix: remove ecosystem imports, fix module installer signature
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 13s
Strip 21 blank imports that dragged go-ml, go-devops, go-build, etc.
into the CLI binary. core CLI should only have its own commands.
Fixes marketplace.NewInstaller call to match current signature.

Direct deps: core/go only (was: 22 forge packages).
Binary: 24MB (was: ~80MB). Cross-compiles with CGO_ENABLED=0.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 15:56:43 +00:00
Snider
c6f9f41e0b chore: update dependencies
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 19s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-15 15:43:33 +00:00
121 changed files with 5751 additions and 6037 deletions

44
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,9 +10,16 @@
// Provides platform-specific installation instructions for missing tools. // Provides platform-specific installation instructions for missing tools.
package doctor package doctor
import "github.com/spf13/cobra" import (
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
)
// AddDoctorCommands registers the 'doctor' command and all subcommands. // AddDoctorCommands registers the 'doctor' command and all subcommands.
//
// doctor.AddDoctorCommands(rootCmd)
func AddDoctorCommands(root *cobra.Command) { func AddDoctorCommands(root *cobra.Command) {
doctorCmd.Short = i18n.T("cmd.doctor.short")
doctorCmd.Long = i18n.T("cmd.doctor.long")
root.AddCommand(doctorCmd) root.AddCommand(doctorCmd)
} }

View file

@ -0,0 +1,117 @@
// Package doctor provides environment check commands.
package doctor
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
)
// Style aliases from shared
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
)
// Flag variable for doctor command
var doctorVerbose bool
var doctorCmd = &cobra.Command{
Use: "doctor",
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctor(doctorVerbose)
},
}
func init() {
doctorCmd.Flags().BoolVar(&doctorVerbose, "verbose", false, i18n.T("cmd.doctor.verbose_flag"))
}
func runDoctor(verbose bool) error {
cli.Println("%s", i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
cli.Blank()
var passed, failed, optional int
// Check required tools
cli.Println("%s", i18n.T("cmd.doctor.required"))
for _, toolCheck := range requiredChecks() {
ok, version := runCheck(toolCheck)
if ok {
if verbose {
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
} else {
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
}
passed++
} else {
cli.Println(" %s %s - %s", errorStyle.Render(cli.Glyph(":cross:")), toolCheck.name, toolCheck.description)
failed++
}
}
// Check optional tools
cli.Println("\n%s", i18n.T("cmd.doctor.optional"))
for _, toolCheck := range optionalChecks() {
ok, version := runCheck(toolCheck)
if ok {
if verbose {
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
} else {
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
}
passed++
} else {
cli.Println(" %s %s - %s", dimStyle.Render(cli.Glyph(":skip:")), toolCheck.name, dimStyle.Render(toolCheck.description))
optional++
}
}
// Check GitHub access
cli.Println("\n%s", i18n.T("cmd.doctor.github"))
if checkGitHubSSH() {
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
} else {
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
failed++
}
if checkGitHubCLI() {
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
} else {
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
failed++
}
// Check workspace
cli.Println("\n%s", i18n.T("cmd.doctor.workspace"))
checkWorkspace()
// Summary
cli.Blank()
if failed > 0 {
cli.Error(i18n.T("cmd.doctor.issues", map[string]any{"Count": failed}))
cli.Println("\n%s", i18n.T("cmd.doctor.install_missing"))
printInstallInstructions()
return cli.Err("%s", i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
}
cli.Success(i18n.T("cmd.doctor.ready"))
return nil
}
func formatCheckResult(ok bool, name, detail string) string {
checkBuilder := cli.Check(name)
if ok {
checkBuilder.Pass()
} else {
checkBuilder.Fail()
}
if detail != "" {
checkBuilder.Message(detail)
} else {
checkBuilder.Message("")
}
return checkBuilder.String()
}

View file

@ -0,0 +1,77 @@
package doctor
import (
"os"
"os/exec"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
io "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
)
// checkGitHubSSH checks if SSH keys exist for GitHub access.
// Returns true if any standard SSH key file exists in ~/.ssh/.
func checkGitHubSSH() bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
sshDirectory := core.Path(home, ".ssh")
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
for _, keyName := range keyPatterns {
keyPath := core.Path(sshDirectory, keyName)
if _, err := os.Stat(keyPath); err == nil {
return true
}
}
return false
}
// checkGitHubCLI checks if the GitHub CLI is authenticated.
// Returns true when 'gh auth status' output contains "Logged in to".
func checkGitHubCLI() bool {
proc := exec.Command("gh", "auth", "status")
output, _ := proc.CombinedOutput()
return core.Contains(string(output), "Logged in to")
}
// checkWorkspace checks for repos.yaml and counts cloned repos.
func checkWorkspace() {
registryPath, err := repos.FindRegistry(io.Local)
if err == nil {
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
registry, err := repos.LoadRegistry(io.Local, registryPath)
if err == nil {
basePath := registry.BasePath
if basePath == "" {
basePath = "./packages"
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
if core.HasPrefix(basePath, "~/") {
home, _ := os.UserHomeDir()
basePath = core.Path(home, basePath[2:])
}
// Count existing repos.
allRepos := registry.List()
var cloned int
for _, repo := range allRepos {
repoPath := core.Path(basePath, repo.Name)
if _, err := os.Stat(core.Path(repoPath, ".git")); err == nil {
cloned++
}
}
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
}
} else {
cli.Println(" %s %s", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
}
}

View file

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

116
cmd/core/go.mod Normal file
View file

@ -0,0 +1,116 @@
module forge.lthn.ai/core/cli/cmd/core
go 1.26.0
require (
forge.lthn.ai/core/cli v0.3.3
forge.lthn.ai/core/config v0.1.3
forge.lthn.ai/core/go-build v0.2.3
forge.lthn.ai/core/go-cache v0.1.2
forge.lthn.ai/core/go-crypt v0.1.7
forge.lthn.ai/core/go-devops v0.1.9
forge.lthn.ai/core/go-help v0.1.3
forge.lthn.ai/core/go-i18n v0.1.4
forge.lthn.ai/core/go-io v0.1.2
forge.lthn.ai/core/go-scm v0.3.1
forge.lthn.ai/core/lint v0.3.2
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go v0.123.0 // indirect
code.gitea.io/sdk/gitea v0.23.2 // indirect
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
forge.lthn.ai/core/agent v0.3.1 // indirect
forge.lthn.ai/core/go v0.3.1 // indirect
forge.lthn.ai/core/go-container v0.1.3 // indirect
forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
forge.lthn.ai/core/go-process v0.2.3 // indirect
forge.lthn.ai/core/go-store v0.1.6 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/Snider/Borg v0.2.0 // indirect
github.com/TwiN/go-color v1.4.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // 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
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/getkin/kin-openapi v0.134.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 // indirect
github.com/leaanthony/debme v1.2.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.9.2 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // 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
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oasdiff/kin-openapi v0.136.1 // indirect
github.com/oasdiff/oasdiff v1.12.3 // indirect
github.com/oasdiff/yaml v0.0.1 // indirect
github.com/oasdiff/yaml3 v0.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/wI2L/jsondiff v0.7.0 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
)

299
cmd/core/go.sum Normal file
View file

@ -0,0 +1,299 @@
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
forge.lthn.ai/core/agent v0.3.1 h1:Q6lkSg9nr2c1oj2Pr/s3LN7xItIyOmSRgLSfMaaNuyQ=
forge.lthn.ai/core/agent v0.3.1/go.mod h1:FfHS10AkPcxnc+ms93QzNJtZ7dgcET0LvMcJAzY2h+w=
forge.lthn.ai/core/cli v0.3.3 h1:dWvpiLZifuydqU4eH5+UdgCQ6/LOpS1x+O03pU7jkhk=
forge.lthn.ai/core/cli v0.3.3/go.mod h1:PJ/cTufrVLz4KdlBhUkT/sOeh6uOSN6W7+/IvglRoBU=
forge.lthn.ai/core/config v0.1.3 h1:mq02v7LFf9jHSqJakO08qYQnPP8oVMbJHlOxNARXBa8=
forge.lthn.ai/core/config v0.1.3/go.mod h1:4+/ytojOSaPoAQ1uub1+GeOM8OoYdR9xqMtVA3SZ8Qk=
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
forge.lthn.ai/core/go-build v0.2.3 h1:iTb0YpJj7PFAWJ+LmO6lusUfYvnyKIfABi1ohnYrdcw=
forge.lthn.ai/core/go-build v0.2.3/go.mod h1:0CVFglD7cc07ew1c9IEv/BAniHGndHGwrWk27m4c4L8=
forge.lthn.ai/core/go-cache v0.1.2 h1:mIt+dqe2Gnqcj3Q6y6wGOXu0MklPO/oWuF09UZUmS6w=
forge.lthn.ai/core/go-cache v0.1.2/go.mod h1:7WbprZVfx/+t4cbJFXMo4sloWk2Eny+rZd8x1Ay9rLk=
forge.lthn.ai/core/go-container v0.1.3 h1:Pb/latnVFBgyI4zDyYxAiRRqKrOYIAxL6om+k2YS1q8=
forge.lthn.ai/core/go-container v0.1.3/go.mod h1:wIlly3pAluVQnQ+DLnZ15pENOFkJicWRRke6msCudLI=
forge.lthn.ai/core/go-crypt v0.1.7 h1:tyDFnXjEksHFQpkFwCpEn+x7zvwh4LnaU+/fP3WmqZc=
forge.lthn.ai/core/go-crypt v0.1.7/go.mod h1:mQdr6K8lWOcyHmSEW24vZPTThQF8fteVgZi8CO+Ko3Y=
forge.lthn.ai/core/go-devops v0.1.9 h1:pgGTvCDeg1SgJIkpZfy1l6ZvkOGGGY+fa3aAcl3vRG4=
forge.lthn.ai/core/go-devops v0.1.9/go.mod h1:uY37IzpargbgDBwazqYv6X5+e2bcCO+cn0jCYQA/YMk=
forge.lthn.ai/core/go-help v0.1.3 h1:eKrj3o3ruvDD3c6NWUve8Y/uMqpfIE5/yR2eU6gdAF0=
forge.lthn.ai/core/go-help v0.1.3/go.mod h1:JSZVb4Gd+P/dTc9laDJsqVCI6OrVbBbBPyPmvw3j4p4=
forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ=
forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U=
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-process v0.2.3 h1:/ERqRYHgCNZjNT9NMinAAJJGJWSsHuCTiHFNEm6nTPY=
forge.lthn.ai/core/go-process v0.2.3/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
forge.lthn.ai/core/go-scm v0.3.1 h1:G+DqVJLT+UjgUzu2Hnseyl2szhb0wB+DB8VYhq/bLOI=
forge.lthn.ai/core/go-scm v0.3.1/go.mod h1:ER9fQBs8nnlJZQ6+ALnwv+boK/xiwg8jEc9VP6DMijk=
forge.lthn.ai/core/go-store v0.1.6 h1:7T+K5cciXOaWRxge0WnGkt0PcK3epliWBa1G2FLEuac=
forge.lthn.ai/core/go-store v0.1.6/go.mod h1:/2vqaAn+HgGU14N29B+vIfhjIsBzy7RC+AluI6BIUKI=
forge.lthn.ai/core/lint v0.3.2 h1:3ZzHfb4OQS0r0NsQpsIrnBscgOE058KIDty3b45r00E=
forge.lthn.ai/core/lint v0.3.2/go.mod h1:fInfXFlOCljqWh6fkjHqAUXok5vhblKc+toQJIihIPY=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1/go.mod h1:3ebNU9QBrNpUO+Hj6bHaGpkh5pymDHQ+wwVPHTE4mCE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oasdiff/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=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

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

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

33
cmd/core/main.go Normal file
View file

@ -0,0 +1,33 @@
package main
import (
"forge.lthn.ai/core/cli/cmd/core/config"
"forge.lthn.ai/core/cli/cmd/core/doctor"
"forge.lthn.ai/core/cli/cmd/core/help"
"forge.lthn.ai/core/cli/cmd/core/pkgcmd"
"forge.lthn.ai/core/cli/pkg/cli"
// Ecosystem commands — self-register via init() + cli.RegisterCommands()
_ "forge.lthn.ai/core/go-build/cmd/build"
_ "forge.lthn.ai/core/go-build/cmd/ci"
_ "forge.lthn.ai/core/go-build/cmd/sdk"
_ "forge.lthn.ai/core/go-crypt/cmd/crypt"
_ "forge.lthn.ai/core/go-devops/cmd/deploy"
_ "forge.lthn.ai/core/go-devops/cmd/dev"
_ "forge.lthn.ai/core/go-devops/cmd/docs"
_ "forge.lthn.ai/core/go-devops/cmd/gitcmd"
_ "forge.lthn.ai/core/go-devops/cmd/setup"
_ "forge.lthn.ai/core/go-scm/cmd/collect"
_ "forge.lthn.ai/core/go-scm/cmd/forge"
_ "forge.lthn.ai/core/go-scm/cmd/gitea"
_ "forge.lthn.ai/core/lint/cmd/qa"
)
func main() {
cli.Main(
cli.WithCommands("config", config.AddConfigCommands),
cli.WithCommands("doctor", doctor.AddDoctorCommands),
cli.WithCommands("help", help.AddHelpCommands),
cli.WithCommands("pkg", pkgcmd.AddPkgCommands),
)
}

View file

@ -0,0 +1,156 @@
package pkgcmd
import (
"context"
"os"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
var (
installTargetDir string
installAddToReg bool
)
// addPkgInstallCommand adds the 'pkg install' command.
func addPkgInstallCommand(parent *cobra.Command) {
installCmd := &cobra.Command{
Use: "install <org/repo>",
Short: i18n.T("cmd.pkg.install.short"),
Long: i18n.T("cmd.pkg.install.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgInstall(args[0], installTargetDir, installAddToReg)
},
}
installCmd.Flags().StringVar(&installTargetDir, "dir", "", i18n.T("cmd.pkg.install.flag.dir"))
installCmd.Flags().BoolVar(&installAddToReg, "add", false, i18n.T("cmd.pkg.install.flag.add"))
parent.AddCommand(installCmd)
}
func runPkgInstall(repoArg, targetDirectory string, addToRegistry bool) error {
ctx := context.Background()
// Parse org/repo argument.
parts := core.Split(repoArg, "/")
if len(parts) != 2 {
return cli.Err(i18n.T("cmd.pkg.error.invalid_repo_format"))
}
org, repoName := parts[0], parts[1]
// Determine target directory from registry or default.
if targetDirectory == "" {
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
if registry, err := repos.LoadRegistry(coreio.Local, registryPath); err == nil {
targetDirectory = registry.BasePath
if targetDirectory == "" {
targetDirectory = "./packages"
}
if !core.PathIsAbs(targetDirectory) {
targetDirectory = core.Path(core.PathDir(registryPath), targetDirectory)
}
}
}
if targetDirectory == "" {
targetDirectory = "."
}
}
if core.HasPrefix(targetDirectory, "~/") {
home, _ := os.UserHomeDir()
targetDirectory = core.Path(home, targetDirectory[2:])
}
repoPath := core.Path(targetDirectory, repoName)
if coreio.Local.Exists(core.Path(repoPath, ".git")) {
cli.Println("%s %s", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
return nil
}
if err := coreio.Local.EnsureDir(targetDirectory); err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.create", "directory"))
}
cli.Println("%s %s/%s", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
cli.Println("%s %s", dimStyle.Render(i18n.Label("target")), repoPath)
cli.Blank()
cli.Print(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
err := gitClone(ctx, org, repoName, repoPath)
if err != nil {
cli.Println("%s", errorStyle.Render("✗ "+err.Error()))
return err
}
cli.Println("%s", successStyle.Render("✓"))
if addToRegistry {
if err := addToRegistryFile(org, repoName); err != nil {
cli.Println(" %s %s: %s", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
} else {
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
}
}
cli.Blank()
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
return nil
}
func addToRegistryFile(org, repoName string) error {
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return err
}
if _, exists := registry.Get(repoName); exists {
return nil
}
content, err := coreio.Local.Read(registryPath)
if err != nil {
return err
}
repoType := detectRepoType(repoName)
entry := cli.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType)
content += entry
return coreio.Local.Write(registryPath, content)
}
func detectRepoType(name string) string {
lowerName := core.Lower(name)
if core.Contains(lowerName, "-mod-") || core.HasSuffix(lowerName, "-mod") {
return "module"
}
if core.Contains(lowerName, "-plug-") || core.HasSuffix(lowerName, "-plug") {
return "plugin"
}
if core.Contains(lowerName, "-services-") || core.HasSuffix(lowerName, "-services") {
return "service"
}
if core.Contains(lowerName, "-website-") || core.HasSuffix(lowerName, "-website") {
return "website"
}
if core.HasPrefix(lowerName, "core-") {
return "package"
}
return "package"
}

View file

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

View file

@ -0,0 +1,254 @@
package pkgcmd
import (
"os/exec"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
// addPkgListCommand adds the 'pkg list' command.
func addPkgListCommand(parent *cobra.Command) {
listCmd := &cobra.Command{
Use: "list",
Short: i18n.T("cmd.pkg.list.short"),
Long: i18n.T("cmd.pkg.list.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runPkgList()
},
}
parent.AddCommand(listCmd)
}
func runPkgList() error {
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
allRepos := registry.List()
if len(allRepos) == 0 {
cli.Println("%s", i18n.T("cmd.pkg.list.no_packages"))
return nil
}
cli.Println("%s\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
var installed, missing int
for _, repo := range allRepos {
repoPath := core.Path(basePath, repo.Name)
exists := coreio.Local.Exists(core.Path(repoPath, ".git"))
if exists {
installed++
} else {
missing++
}
status := successStyle.Render("✓")
if !exists {
status = dimStyle.Render("○")
}
description := repo.Description
if len(description) > 40 {
description = description[:37] + "..."
}
if description == "" {
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
cli.Println(" %s %s", status, repoNameStyle.Render(repo.Name))
cli.Println(" %s", description)
}
cli.Blank()
cli.Println("%s %s", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
if missing > 0 {
cli.Println("\n%s %s", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
}
return nil
}
var updateAll bool
// addPkgUpdateCommand adds the 'pkg update' command.
func addPkgUpdateCommand(parent *cobra.Command) {
updateCmd := &cobra.Command{
Use: "update [packages...]",
Short: i18n.T("cmd.pkg.update.short"),
Long: i18n.T("cmd.pkg.update.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if !updateAll && len(args) == 0 {
return cli.Err(i18n.T("cmd.pkg.error.specify_package"))
}
return runPkgUpdate(args, updateAll)
},
}
updateCmd.Flags().BoolVar(&updateAll, "all", false, i18n.T("cmd.pkg.update.flag.all"))
parent.AddCommand(updateCmd)
}
func runPkgUpdate(packages []string, all bool) error {
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
var toUpdate []string
if all {
for _, repo := range registry.List() {
toUpdate = append(toUpdate, repo.Name)
}
} else {
toUpdate = packages
}
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
var updated, skipped, failed int
for _, name := range toUpdate {
repoPath := core.Path(basePath, name)
if _, err := coreio.Local.List(core.Path(repoPath, ".git")); err != nil {
cli.Println(" %s %s (%s)", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
skipped++
continue
}
cli.Print(" %s %s... ", dimStyle.Render("↓"), name)
proc := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := proc.CombinedOutput()
if err != nil {
cli.Println("%s", errorStyle.Render("✗"))
cli.Println(" %s", core.Trim(string(output)))
failed++
continue
}
if core.Contains(string(output), "Already up to date") {
cli.Println("%s", dimStyle.Render(i18n.T("common.status.up_to_date")))
} else {
cli.Println("%s", successStyle.Render("✓"))
}
updated++
}
cli.Blank()
cli.Println("%s %s",
dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed}))
return nil
}
// addPkgOutdatedCommand adds the 'pkg outdated' command.
func addPkgOutdatedCommand(parent *cobra.Command) {
outdatedCmd := &cobra.Command{
Use: "outdated",
Short: i18n.T("cmd.pkg.outdated.short"),
Long: i18n.T("cmd.pkg.outdated.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runPkgOutdated()
},
}
parent.AddCommand(outdatedCmd)
}
func runPkgOutdated() error {
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
var outdated, upToDate, notInstalled int
for _, repo := range registry.List() {
repoPath := core.Path(basePath, repo.Name)
if !coreio.Local.Exists(core.Path(repoPath, ".git")) {
notInstalled++
continue
}
// Fetch updates silently.
_ = exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run()
// Check commit count behind upstream.
proc := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
output, err := proc.Output()
if err != nil {
continue
}
commitCount := core.Trim(string(output))
if commitCount != "0" {
cli.Println(" %s %s (%s)",
errorStyle.Render("↓"), repoNameStyle.Render(repo.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": commitCount}))
outdated++
} else {
upToDate++
}
}
cli.Blank()
if outdated == 0 {
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
} else {
cli.Println("%s %s",
dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate}))
cli.Println("\n%s %s", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
}
return nil
}

View file

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

View file

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

View file

@ -0,0 +1,145 @@
// cmd_remove.go implements the 'pkg remove' command with safety checks.
//
// Before removing a package, it verifies:
// 1. No uncommitted changes exist
// 2. No unpushed branches exist
// This prevents accidental data loss from agents or tools that might
// attempt to remove packages without cleaning up first.
package pkgcmd
import (
"os/exec"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
var removeForce bool
func addPkgRemoveCommand(parent *cobra.Command) {
removeCmd := &cobra.Command{
Use: "remove <package>",
Short: "Remove a package (with safety checks)",
Long: `Removes a package directory after verifying it has no uncommitted
changes or unpushed branches. Use --force to skip safety checks.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgRemove(args[0], removeForce)
},
}
removeCmd.Flags().BoolVar(&removeForce, "force", false, "Skip safety checks (dangerous)")
parent.AddCommand(removeCmd)
}
func runPkgRemove(name string, force bool) error {
// Find package path via registry.
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
}
basePath := registry.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
}
repoPath := core.Path(basePath, name)
if !coreio.Local.IsDir(core.Path(repoPath, ".git")) {
return cli.Err("package %s is not installed at %s", name, repoPath)
}
if !force {
blocked, reasons := checkRepoSafety(repoPath)
if blocked {
cli.Println("%s Cannot remove %s:", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
for _, reason := range reasons {
cli.Println(" %s %s", errorStyle.Render("·"), reason)
}
cli.Println("\nResolve the issues above or use --force to override.")
return cli.Err("package has unresolved changes")
}
}
// Remove the directory.
cli.Print("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
if err := coreio.Local.DeleteAll(repoPath); err != nil {
cli.Println("%s", errorStyle.Render("x "+err.Error()))
return err
}
cli.Println("%s", successStyle.Render("ok"))
return nil
}
// checkRepoSafety checks a git repo for uncommitted changes and unpushed branches.
//
// blocked, reasons := checkRepoSafety("/path/to/repo")
// if blocked { fmt.Println(reasons) }
func checkRepoSafety(repoPath string) (blocked bool, reasons []string) {
// Check for uncommitted changes (staged, unstaged, untracked).
proc := exec.Command("git", "-C", repoPath, "status", "--porcelain")
output, err := proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
blocked = true
reasons = append(reasons, cli.Sprintf("has %d uncommitted changes", len(lines)))
}
// Check for unpushed commits on current branch.
proc = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
output, err = proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
blocked = true
reasons = append(reasons, cli.Sprintf("has %d unpushed commits on current branch", len(lines)))
}
// Check all local branches for unpushed work.
proc = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
output, _ = proc.Output()
if trimmedOutput := core.Trim(string(output)); trimmedOutput != "" {
branches := core.Split(trimmedOutput, "\n")
var unmerged []string
for _, branchName := range branches {
branchName = core.Trim(branchName)
branchName = core.TrimPrefix(branchName, "* ")
if branchName != "" {
unmerged = append(unmerged, branchName)
}
}
if len(unmerged) > 0 {
blocked = true
reasons = append(reasons, cli.Sprintf("has %d unmerged branches: %s",
len(unmerged), core.Join(", ", unmerged...)))
}
}
// Check for stashed changes.
proc = exec.Command("git", "-C", repoPath, "stash", "list")
output, err = proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
blocked = true
reasons = append(reasons, cli.Sprintf("has %d stashed entries", len(lines)))
}
return blocked, reasons
}

View file

@ -0,0 +1,174 @@
package pkgcmd
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestRepo(t *testing.T, dir, name string) string {
t.Helper()
repoPath := filepath.Join(dir, name)
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")
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")
blocked, reasons := checkRepoSafety(repoPath)
assert.False(t, blocked)
assert.Empty(t, reasons)
}
func TestCheckRepoSafety_UncommittedChanges(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "dirty-repo")
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "new.txt"), []byte("data"), 0644))
blocked, reasons := checkRepoSafety(repoPath)
assert.True(t, blocked)
assert.NotEmpty(t, reasons)
assert.Contains(t, reasons[0], "uncommitted changes")
}
func TestCheckRepoSafety_Stash(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "stash-repo")
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644))
gitCommand(t, repoPath, "add", ".")
gitCommand(t, repoPath, "stash")
blocked, reasons := checkRepoSafety(repoPath)
assert.True(t, blocked)
found := false
for _, r := range reasons {
if strings.Contains(r, "stash") {
found = true
}
}
assert.True(t, found, "expected stash warning in reasons: %v", reasons)
}
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 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,214 @@
package pkgcmd
import (
"cmp"
"os/exec"
"slices"
"time"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-cache"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
var (
searchOrg string
searchPattern string
searchType string
searchLimit int
searchRefresh bool
)
// addPkgSearchCommand adds the 'pkg search' command.
func addPkgSearchCommand(parent *cobra.Command) {
searchCmd := &cobra.Command{
Use: "search",
Short: i18n.T("cmd.pkg.search.short"),
Long: i18n.T("cmd.pkg.search.long"),
RunE: func(cmd *cobra.Command, args []string) error {
org := searchOrg
pattern := searchPattern
limit := searchLimit
if org == "" {
org = "host-uk"
}
if pattern == "" {
pattern = "*"
}
if limit == 0 {
limit = 50
}
return runPkgSearch(org, pattern, searchType, limit, searchRefresh)
},
}
searchCmd.Flags().StringVar(&searchOrg, "org", "", i18n.T("cmd.pkg.search.flag.org"))
searchCmd.Flags().StringVar(&searchPattern, "pattern", "", i18n.T("cmd.pkg.search.flag.pattern"))
searchCmd.Flags().StringVar(&searchType, "type", "", i18n.T("cmd.pkg.search.flag.type"))
searchCmd.Flags().IntVar(&searchLimit, "limit", 0, i18n.T("cmd.pkg.search.flag.limit"))
searchCmd.Flags().BoolVar(&searchRefresh, "refresh", false, i18n.T("cmd.pkg.search.flag.refresh"))
parent.AddCommand(searchCmd)
}
type ghRepo struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
UpdatedAt string `json:"updated_at"`
Language string `json:"language"`
}
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
// Initialise cache in workspace .core/ directory.
var cacheDirectory string
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
cacheDirectory = core.Path(core.PathDir(registryPath), ".core", "cache")
}
cacheInstance, err := cache.New(coreio.Local, cacheDirectory, 0)
if err != nil {
cacheInstance = nil
}
cacheKey := cache.GitHubReposKey(org)
var ghRepos []ghRepo
var fromCache bool
// Try cache first (unless refresh requested).
if cacheInstance != nil && !refresh {
if found, err := cacheInstance.Get(cacheKey, &ghRepos); found && err == nil {
fromCache = true
age := cacheInstance.Age(cacheKey)
cli.Println("%s %s %s", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(cli.Sprintf("(%s ago)", age.Round(time.Second))))
}
}
// Fetch from GitHub if not cached.
if !fromCache {
if !ghAuthenticated() {
return cli.Err(i18n.T("cmd.pkg.error.gh_not_authenticated"))
}
if core.Env("GH_TOKEN") != "" {
cli.Println("%s %s", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
cli.Println("%s %s\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
}
cli.Print("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
proc := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", cli.Sprintf("%d", limit))
output, err := proc.CombinedOutput()
if err != nil {
cli.Blank()
errorOutput := core.Trim(string(output))
if core.Contains(errorOutput, "401") || core.Contains(errorOutput, "Bad credentials") {
return cli.Err(i18n.T("cmd.pkg.error.auth_failed"))
}
return cli.Err("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errorOutput)
}
result := core.JSONUnmarshal(output, &ghRepos)
if !result.OK {
return cli.Wrap(result.Value.(error), i18n.T("i18n.fail.parse", "results"))
}
if cacheInstance != nil {
_ = cacheInstance.Set(cacheKey, ghRepos)
}
cli.Println("%s", successStyle.Render("✓"))
}
// Filter by glob pattern and type.
var filtered []ghRepo
for _, repo := range ghRepos {
if !matchGlob(pattern, repo.Name) {
continue
}
if repoType != "" && !core.Contains(repo.Name, repoType) {
continue
}
filtered = append(filtered, repo)
}
if len(filtered) == 0 {
cli.Println("%s", i18n.T("cmd.pkg.search.no_repos_found"))
return nil
}
slices.SortFunc(filtered, func(a, b ghRepo) int {
return cmp.Compare(a.Name, b.Name)
})
cli.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
for _, repo := range filtered {
visibility := ""
if repo.Visibility == "private" {
visibility = dimStyle.Render(" " + i18n.T("cmd.pkg.search.private_label"))
}
description := repo.Description
if len(description) > 50 {
description = description[:47] + "..."
}
if description == "" {
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
cli.Println(" %s%s", repoNameStyle.Render(repo.Name), visibility)
cli.Println(" %s", description)
}
cli.Blank()
cli.Println("%s %s", i18n.T("common.hint.install_with"), dimStyle.Render(cli.Sprintf("core pkg install %s/<repo-name>", org)))
return nil
}
// matchGlob does simple glob matching with * wildcards.
//
// matchGlob("core-*", "core-php") // true
// matchGlob("*-mod", "core-php") // false
func matchGlob(pattern, name string) bool {
if pattern == "*" || pattern == "" {
return true
}
parts := core.Split(pattern, "*")
pos := 0
for i, part := range parts {
if part == "" {
continue
}
// Find part in name starting from pos.
remaining := name[pos:]
idx := -1
for j := 0; j <= len(remaining)-len(part); j++ {
if remaining[j:j+len(part)] == part {
idx = j
break
}
}
if idx == -1 {
return false
}
if i == 0 && !core.HasPrefix(pattern, "*") && idx != 0 {
return false
}
pos += idx + len(part)
}
if !core.HasSuffix(pattern, "*") && pos != len(name) {
return false
}
return true
}

View file

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

View file

@ -1,122 +0,0 @@
// Package doctor provides environment check commands.
package doctor
import (
"errors"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
)
// Style aliases from shared
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
)
// Flag variable for doctor command
var doctorVerbose bool
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: i18n.T("cmd.doctor.short"),
Long: i18n.T("cmd.doctor.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctor(doctorVerbose)
},
}
func init() {
doctorCmd.Flags().BoolVar(&doctorVerbose, "verbose", false, i18n.T("cmd.doctor.verbose_flag"))
}
func runDoctor(verbose bool) error {
fmt.Println(i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
fmt.Println()
var passed, failed, optional int
// Check required tools
fmt.Println(i18n.T("cmd.doctor.required"))
for _, c := range requiredChecks() {
ok, version := runCheck(c)
if ok {
if verbose {
fmt.Println(formatCheckResult(true, c.name, version))
} else {
fmt.Println(formatCheckResult(true, c.name, ""))
}
passed++
} else {
fmt.Printf(" %s %s - %s\n", errorStyle.Render(cli.Glyph(":cross:")), c.name, c.description)
failed++
}
}
// Check optional tools
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.optional"))
for _, c := range optionalChecks() {
ok, version := runCheck(c)
if ok {
if verbose {
fmt.Println(formatCheckResult(true, c.name, version))
} else {
fmt.Println(formatCheckResult(true, c.name, ""))
}
passed++
} else {
fmt.Printf(" %s %s - %s\n", dimStyle.Render(cli.Glyph(":skip:")), c.name, dimStyle.Render(c.description))
optional++
}
}
// Check GitHub access
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.github"))
if checkGitHubSSH() {
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
} else {
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
failed++
}
if checkGitHubCLI() {
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
} else {
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
failed++
}
// Check workspace
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.workspace"))
checkWorkspace()
// Summary
fmt.Println()
if failed > 0 {
cli.Error(i18n.T("cmd.doctor.issues", map[string]any{"Count": failed}))
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.install_missing"))
printInstallInstructions()
return errors.New(i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
}
cli.Success(i18n.T("cmd.doctor.ready"))
return nil
}
func formatCheckResult(ok bool, name, detail string) string {
check := cli.Check(name)
if ok {
check.Pass()
} else {
check.Fail()
}
if detail != "" {
check.Message(detail)
} else {
check.Message("")
}
return check.String()
}

View file

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

View file

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

View file

@ -1,15 +0,0 @@
// Package gocmd provides Go development commands with enhanced output.
//
// Note: Package named gocmd because 'go' is a reserved keyword.
//
// Commands:
// - test: Run tests with colour-coded coverage summary
// - cov: Run tests with detailed coverage reports (HTML, thresholds)
// - fmt: Format code using goimports or gofmt
// - lint: Run golangci-lint
// - install: Install binary to $GOPATH/bin
// - mod: Module management (tidy, download, verify, graph)
// - work: Workspace management (sync, init, use)
//
// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS.
package gocmd

View file

@ -1,177 +0,0 @@
package gocmd
import (
"bufio"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
var (
fmtFix bool
fmtDiff bool
fmtCheck bool
fmtAll bool
)
func addGoFmtCommand(parent *cli.Command) {
fmtCmd := &cli.Command{
Use: "fmt",
Short: "Format Go code",
Long: "Format Go code using goimports or gofmt. By default only checks changed files.",
RunE: func(cmd *cli.Command, args []string) error {
// Get list of files to check
var files []string
if fmtAll {
// Check all Go files
files = []string{"."}
} else {
// Only check changed Go files (git-aware)
files = getChangedGoFiles()
if len(files) == 0 {
cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes"))
return nil
}
}
// Validate flag combinations
if fmtCheck && fmtFix {
return cli.Err("--check and --fix are mutually exclusive")
}
fmtArgs := []string{}
if fmtFix {
fmtArgs = append(fmtArgs, "-w")
}
if fmtDiff {
fmtArgs = append(fmtArgs, "-d")
}
if !fmtFix && !fmtDiff {
fmtArgs = append(fmtArgs, "-l")
}
fmtArgs = append(fmtArgs, files...)
// Try goimports first, fall back to gofmt
var execCmd *exec.Cmd
if _, err := exec.LookPath("goimports"); err == nil {
execCmd = exec.Command("goimports", fmtArgs...)
} else {
execCmd = exec.Command("gofmt", fmtArgs...)
}
// For --check mode, capture output to detect unformatted files
if fmtCheck {
output, err := execCmd.CombinedOutput()
if err != nil {
_, _ = os.Stderr.Write(output)
return err
}
if len(output) > 0 {
_, _ = os.Stdout.Write(output)
return cli.Err("files need formatting (use --fix)")
}
return nil
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check"))
fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all"))
parent.AddCommand(fmtCmd)
}
// getChangedGoFiles returns Go files that have been modified, staged, or are untracked.
func getChangedGoFiles() []string {
var files []string
// Get modified and staged files
cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD")
output, err := cmd.Output()
if err == nil {
files = append(files, filterGoFiles(string(output))...)
}
// Get untracked files
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
output, err = cmd.Output()
if err == nil {
files = append(files, filterGoFiles(string(output))...)
}
// Deduplicate
seen := make(map[string]bool)
var unique []string
for _, f := range files {
if !seen[f] {
seen[f] = true
// Verify file exists (might have been deleted)
if _, err := os.Stat(f); err == nil {
unique = append(unique, f)
}
}
}
return unique
}
// filterGoFiles filters a newline-separated list of files to only include .go files.
func filterGoFiles(output string) []string {
var goFiles []string
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
file := strings.TrimSpace(scanner.Text())
if file != "" && filepath.Ext(file) == ".go" {
goFiles = append(goFiles, file)
}
}
return goFiles
}
var (
lintFix bool
lintAll bool
)
func addGoLintCommand(parent *cli.Command) {
lintCmd := &cli.Command{
Use: "lint",
Short: "Run golangci-lint",
Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.",
RunE: func(cmd *cli.Command, args []string) error {
lintArgs := []string{"run"}
if lintFix {
lintArgs = append(lintArgs, "--fix")
}
if !lintAll {
// Use --new-from-rev=HEAD to only report issues in uncommitted changes
// This is golangci-lint's native way to handle incremental linting
lintArgs = append(lintArgs, "--new-from-rev=HEAD")
}
// Always lint all packages
lintArgs = append(lintArgs, "./...")
execCmd := exec.Command("golangci-lint", lintArgs...)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all"))
parent.AddCommand(lintCmd)
}

View file

@ -1,169 +0,0 @@
package gocmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
var (
fuzzDuration time.Duration
fuzzPkg string
fuzzRun string
fuzzVerbose bool
)
func addGoFuzzCommand(parent *cli.Command) {
fuzzCmd := &cli.Command{
Use: "fuzz",
Short: "Run Go fuzz tests",
Long: `Run Go fuzz tests with configurable duration.
Discovers Fuzz* functions across the project and runs each with go test -fuzz.
Examples:
core go fuzz # Run all fuzz targets for 10s each
core go fuzz --duration=30s # Run each target for 30s
core go fuzz --pkg=./pkg/... # Fuzz specific package
core go fuzz --run=FuzzE # Run only matching fuzz targets`,
RunE: func(cmd *cli.Command, args []string) error {
return runGoFuzz(fuzzDuration, fuzzPkg, fuzzRun, fuzzVerbose)
},
}
fuzzCmd.Flags().DurationVar(&fuzzDuration, "duration", 10*time.Second, "Duration per fuzz target")
fuzzCmd.Flags().StringVar(&fuzzPkg, "pkg", "", "Package to fuzz (default: auto-discover)")
fuzzCmd.Flags().StringVar(&fuzzRun, "run", "", "Only run fuzz targets matching pattern")
fuzzCmd.Flags().BoolVarP(&fuzzVerbose, "verbose", "v", false, "Verbose output")
parent.AddCommand(fuzzCmd)
}
// fuzzTarget represents a discovered fuzz function and its package.
type fuzzTarget struct {
Pkg string
Name string
}
func runGoFuzz(duration time.Duration, pkg, run string, verbose bool) error {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fuzz")), i18n.ProgressSubject("run", "fuzz tests"))
cli.Blank()
targets, err := discoverFuzzTargets(pkg, run)
if err != nil {
return cli.Wrap(err, "discover fuzz targets")
}
if len(targets) == 0 {
cli.Print(" %s no fuzz targets found\n", dimStyle.Render("—"))
return nil
}
cli.Print(" %s %d target(s), %s each\n", dimStyle.Render(i18n.Label("targets")), len(targets), duration)
cli.Blank()
passed := 0
failed := 0
for _, t := range targets {
cli.Print(" %s %s in %s\n", dimStyle.Render("→"), t.Name, t.Pkg)
args := []string{
"test",
fmt.Sprintf("-fuzz=^%s$", t.Name),
fmt.Sprintf("-fuzztime=%s", duration),
"-run=^$", // Don't run unit tests
}
if verbose {
args = append(args, "-v")
}
args = append(args, t.Pkg)
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
cmd.Dir, _ = os.Getwd()
output, runErr := cmd.CombinedOutput()
outputStr := string(output)
if runErr != nil {
failed++
cli.Print(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), runErr.Error())
if outputStr != "" {
cli.Text(outputStr)
}
} else {
passed++
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
if verbose && outputStr != "" {
cli.Text(outputStr)
}
}
}
cli.Blank()
if failed > 0 {
cli.Print("%s %d passed, %d failed\n", errorStyle.Render(cli.Glyph(":cross:")), passed, failed)
return cli.Err("fuzz: %d target(s) failed", failed)
}
cli.Print("%s %d passed\n", successStyle.Render(cli.Glyph(":check:")), passed)
return nil
}
// discoverFuzzTargets scans for Fuzz* functions in test files.
func discoverFuzzTargets(pkg, pattern string) ([]fuzzTarget, error) {
root := "."
if pkg != "" {
// Convert Go package pattern to filesystem path
root = strings.TrimPrefix(pkg, "./")
root = strings.TrimSuffix(root, "/...")
}
fuzzRe := regexp.MustCompile(`^func\s+(Fuzz\w+)\s*\(\s*\w+\s+\*testing\.F\s*\)`)
var matchRe *regexp.Regexp
if pattern != "" {
var err error
matchRe, err = regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid --run pattern: %w", err)
}
}
var targets []fuzzTarget
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || !strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
dir := "./" + filepath.Dir(path)
for line := range strings.SplitSeq(string(data), "\n") {
m := fuzzRe.FindStringSubmatch(line)
if m == nil {
continue
}
name := m[1]
if matchRe != nil && !matchRe.MatchString(name) {
continue
}
targets = append(targets, fuzzTarget{Pkg: dir, Name: name})
}
return nil
})
return targets, err
}

View file

@ -1,36 +0,0 @@
// Package gocmd provides Go development commands.
//
// Note: Package named gocmd because 'go' is a reserved keyword.
package gocmd
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
// Style aliases for shared styles
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
)
// AddGoCommands adds Go development commands.
func AddGoCommands(root *cli.Command) {
goCmd := &cli.Command{
Use: "go",
Short: i18n.T("cmd.go.short"),
Long: i18n.T("cmd.go.long"),
}
root.AddCommand(goCmd)
addGoQACommand(goCmd)
addGoTestCommand(goCmd)
addGoCovCommand(goCmd)
addGoFmtCommand(goCmd)
addGoLintCommand(goCmd)
addGoInstallCommand(goCmd)
addGoModCommand(goCmd)
addGoWorkCommand(goCmd)
addGoFuzzCommand(goCmd)
}

View file

@ -1,430 +0,0 @@
package gocmd
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
var (
testCoverage bool
testPkg string
testRun string
testShort bool
testRace bool
testJSON bool
testVerbose bool
)
func addGoTestCommand(parent *cli.Command) {
testCmd := &cli.Command{
Use: "test",
Short: "Run Go tests",
Long: "Run Go tests with optional coverage, filtering, and race detection",
RunE: func(cmd *cli.Command, args []string) error {
return runGoTest(testCoverage, testPkg, testRun, testShort, testRace, testJSON, testVerbose)
},
}
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, "Generate coverage report")
testCmd.Flags().StringVar(&testPkg, "pkg", "", "Package to test")
testCmd.Flags().StringVar(&testRun, "run", "", "Run only tests matching pattern")
testCmd.Flags().BoolVar(&testShort, "short", false, "Run only short tests")
testCmd.Flags().BoolVar(&testRace, "race", false, "Enable race detector")
testCmd.Flags().BoolVar(&testJSON, "json", false, "Output as JSON")
testCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, "Verbose output")
parent.AddCommand(testCmd)
}
func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error {
if pkg == "" {
pkg = "./..."
}
args := []string{"test"}
var covPath string
if coverage {
args = append(args, "-cover", "-covermode=atomic")
covFile, err := os.CreateTemp("", "coverage-*.out")
if err == nil {
covPath = covFile.Name()
_ = covFile.Close()
args = append(args, "-coverprofile="+covPath)
defer os.Remove(covPath)
}
}
if run != "" {
args = append(args, "-run", run)
}
if short {
args = append(args, "-short")
}
if race {
args = append(args, "-race")
}
if verbose {
args = append(args, "-v")
}
args = append(args, pkg)
if !jsonOut {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests"))
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), pkg)
cli.Blank()
}
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
cmd.Dir, _ = os.Getwd()
output, err := cmd.CombinedOutput()
outputStr := string(output)
// Filter linker warnings
lines := strings.Split(outputStr, "\n")
var filtered []string
for _, line := range lines {
if !strings.Contains(line, "ld: warning:") {
filtered = append(filtered, line)
}
}
outputStr = strings.Join(filtered, "\n")
// Parse results
passed, failed, skipped := parseTestResults(outputStr)
cov := parseOverallCoverage(outputStr)
if jsonOut {
cli.Print(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`,
passed, failed, skipped, cov, cmd.ProcessState.ExitCode())
cli.Blank()
return err
}
// Print filtered output if verbose or failed
if verbose || err != nil {
cli.Text(outputStr)
}
// Summary
if err == nil {
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"))
} else {
cli.Print(" %s %s, %s\n", errorStyle.Render(cli.Glyph(":cross:")),
i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"),
i18n.T("i18n.count.test", failed)+" "+i18n.T("i18n.done.fail"))
}
if cov > 0 {
cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov))
if covPath != "" {
branchCov, err := calculateBlockCoverage(covPath)
if err != nil {
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate"))
} else {
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
}
}
}
if err == nil {
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
} else {
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.done.fail")))
}
return err
}
func parseTestResults(output string) (passed, failed, skipped int) {
passRe := regexp.MustCompile(`(?m)^ok\s+`)
failRe := regexp.MustCompile(`(?m)^FAIL\s+`)
skipRe := regexp.MustCompile(`(?m)^\?\s+`)
passed = len(passRe.FindAllString(output, -1))
failed = len(failRe.FindAllString(output, -1))
skipped = len(skipRe.FindAllString(output, -1))
return
}
func parseOverallCoverage(output string) float64 {
re := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
matches := re.FindAllStringSubmatch(output, -1)
if len(matches) == 0 {
return 0
}
var total float64
for _, m := range matches {
var cov float64
_, _ = fmt.Sscanf(m[1], "%f", &cov)
total += cov
}
return total / float64(len(matches))
}
var (
covPkg string
covHTML bool
covOpen bool
covThreshold float64
covBranchThreshold float64
covOutput string
)
func addGoCovCommand(parent *cli.Command) {
covCmd := &cli.Command{
Use: "cov",
Short: "Run tests with coverage report",
Long: "Run tests with detailed coverage reports, HTML output, and threshold checking",
RunE: func(cmd *cli.Command, args []string) error {
pkg := covPkg
if pkg == "" {
// Auto-discover packages with tests
pkgs, err := findTestPackages(".")
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.find", "test packages"))
}
if len(pkgs) == 0 {
return errors.New("no test packages found")
}
pkg = strings.Join(pkgs, " ")
}
// Create temp file for coverage data
covFile, err := os.CreateTemp("", "coverage-*.out")
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.create", "coverage file"))
}
covPath := covFile.Name()
_ = covFile.Close()
defer func() {
if covOutput == "" {
_ = os.Remove(covPath)
} else {
// Copy to output destination before removing
src, _ := os.Open(covPath)
dst, _ := os.Create(covOutput)
if src != nil && dst != nil {
_, _ = io.Copy(dst, src)
_ = src.Close()
_ = dst.Close()
}
_ = os.Remove(covPath)
}
}()
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests"))
// Truncate package list if too long for display
displayPkg := pkg
if len(displayPkg) > 60 {
displayPkg = displayPkg[:57] + "..."
}
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), displayPkg)
cli.Blank()
// Run tests with coverage
// We need to split pkg into individual arguments if it contains spaces
pkgArgs := strings.Fields(pkg)
cmdArgs := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...)
goCmd := exec.Command("go", cmdArgs...)
goCmd.Env = append(os.Environ(), "CGO_ENABLED=0")
goCmd.Stdout = os.Stdout
goCmd.Stderr = os.Stderr
testErr := goCmd.Run()
// Get coverage percentage
coverCmd := exec.Command("go", "tool", "cover", "-func="+covPath)
covOutput, err := coverCmd.Output()
if err != nil {
if testErr != nil {
return testErr
}
return cli.Wrap(err, i18n.T("i18n.fail.get", "coverage"))
}
// Parse total coverage from last line
lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n")
var statementCov float64
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
// Format: "total: (statements) XX.X%"
if strings.Contains(lastLine, "total:") {
parts := strings.Fields(lastLine)
if len(parts) >= 3 {
covStr := strings.TrimSuffix(parts[len(parts)-1], "%")
_, _ = fmt.Sscanf(covStr, "%f", &statementCov)
}
}
}
// Calculate branch coverage (block coverage)
branchCov, err := calculateBlockCoverage(covPath)
if err != nil {
return cli.Wrap(err, "calculate branch coverage")
}
// Print coverage summary
cli.Blank()
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov))
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
// Generate HTML if requested
if covHTML || covOpen {
htmlPath := "coverage.html"
htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath)
if err := htmlCmd.Run(); err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.generate", "HTML"))
}
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("html")), htmlPath)
if covOpen {
// Open in browser
var openCmd *exec.Cmd
switch {
case exec.Command("which", "open").Run() == nil:
openCmd = exec.Command("open", htmlPath)
case exec.Command("which", "xdg-open").Run() == nil:
openCmd = exec.Command("xdg-open", htmlPath)
default:
cli.Print(" %s\n", dimStyle.Render("Open coverage.html in your browser"))
}
if openCmd != nil {
_ = openCmd.Run()
}
}
}
// Check thresholds
if covThreshold > 0 && statementCov < covThreshold {
cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold)
return errors.New("statement coverage below threshold")
}
if covBranchThreshold > 0 && branchCov < covBranchThreshold {
cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold)
return errors.New("branch coverage below threshold")
}
if testErr != nil {
return testErr
}
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
return nil
},
}
covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test")
covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report")
covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser")
covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage")
covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage")
covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile")
parent.AddCommand(covCmd)
}
// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic
// blocks that have a non-zero execution count. Go's coverage profile contains one line per
// basic block, where the last field is the execution count, not explicit branch coverage.
// The resulting block coverage is used here only as a proxy for branch coverage; computing
// true branch coverage would require more detailed control-flow analysis.
func calculateBlockCoverage(path string) (float64, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var totalBlocks, coveredBlocks int
// Skip the first line (mode: atomic/set/count)
if !scanner.Scan() {
return 0, nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
// Last field is the count
count, err := strconv.Atoi(fields[len(fields)-1])
if err != nil {
continue
}
totalBlocks++
if count > 0 {
coveredBlocks++
}
}
if err := scanner.Err(); err != nil {
return 0, err
}
if totalBlocks == 0 {
return 0, nil
}
return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil
}
func findTestPackages(root string) ([]string, error) {
pkgMap := make(map[string]bool)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") {
dir := filepath.Dir(path)
if !strings.HasPrefix(dir, ".") {
dir = "./" + dir
}
pkgMap[dir] = true
}
return nil
})
if err != nil {
return nil, err
}
var pkgs []string
for pkg := range pkgMap {
pkgs = append(pkgs, pkg)
}
return pkgs, nil
}
func formatCoverage(cov float64) string {
s := fmt.Sprintf("%.1f%%", cov)
if cov >= 80 {
return cli.SuccessStyle.Render(s)
} else if cov >= 50 {
return cli.WarningStyle.Render(s)
}
return cli.ErrorStyle.Render(s)
}

View file

@ -1,635 +0,0 @@
package gocmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/lint/cmd/qa"
"forge.lthn.ai/core/go-i18n"
)
// QA command flags - comprehensive options for all agents
var (
qaFix bool
qaChanged bool
qaAll bool
qaSkip string
qaOnly string
qaCoverage bool
qaThreshold float64
qaBranchThreshold float64
qaDocblockThreshold float64
qaJSON bool
qaVerbose bool
qaQuiet bool
qaTimeout time.Duration
qaShort bool
qaRace bool
qaBench bool
qaFailFast bool
qaMod bool
qaCI bool
)
func addGoQACommand(parent *cli.Command) {
qaCmd := &cli.Command{
Use: "qa",
Short: "Run QA checks",
Long: `Run comprehensive code quality checks for Go projects.
Checks available: fmt, vet, lint, test, race, fuzz, vuln, sec, bench, docblock
Examples:
core go qa # Default: fmt, lint, test
core go qa --fix # Auto-fix formatting and lint issues
core go qa --only=test # Only run tests
core go qa --skip=vuln,sec # Skip vulnerability and security scans
core go qa --coverage --threshold=80 # Require 80% coverage
core go qa --changed # Only check changed files (git-aware)
core go qa --ci # CI mode: strict, coverage, fail-fast
core go qa --race --short # Quick tests with race detection
core go qa --json # Output results as JSON`,
RunE: runGoQA,
}
// Fix and modification flags (persistent so subcommands inherit them)
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks")
// Scope flags
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,fuzz,vuln,sec,bench)")
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
// Coverage flags
qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting")
qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)")
qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below")
qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below")
qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)")
// Test flags
qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag")
qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests")
qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks")
// Output flags
qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON")
qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output")
qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors")
// Control flags
qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks")
qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure")
qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast")
// Preset subcommands for convenience
qaCmd.AddCommand(&cli.Command{
Use: "quick",
Short: "Quick QA: fmt, vet, lint (no tests)",
RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) },
})
qaCmd.AddCommand(&cli.Command{
Use: "full",
Short: "Full QA: all checks including race, vuln, sec",
RunE: func(cmd *cli.Command, args []string) error {
qaOnly = "fmt,vet,lint,test,race,vuln,sec"
return runGoQA(cmd, args)
},
})
qaCmd.AddCommand(&cli.Command{
Use: "pre-commit",
Short: "Pre-commit checks: fmt --fix, lint --fix, test --short",
RunE: func(cmd *cli.Command, args []string) error {
qaFix = true
qaShort = true
qaOnly = "fmt,lint,test"
return runGoQA(cmd, args)
},
})
qaCmd.AddCommand(&cli.Command{
Use: "pr",
Short: "PR checks: full QA with coverage threshold",
RunE: func(cmd *cli.Command, args []string) error {
qaCoverage = true
if qaThreshold == 0 {
qaThreshold = 50 // Default PR threshold
}
qaOnly = "fmt,vet,lint,test"
return runGoQA(cmd, args)
},
})
parent.AddCommand(qaCmd)
}
// QAResult holds the result of a QA run for JSON output
type QAResult struct {
Success bool `json:"success"`
Duration string `json:"duration"`
Checks []CheckResult `json:"checks"`
Coverage *float64 `json:"coverage,omitempty"`
BranchCoverage *float64 `json:"branch_coverage,omitempty"`
Threshold *float64 `json:"threshold,omitempty"`
BranchThreshold *float64 `json:"branch_threshold,omitempty"`
}
// CheckResult holds the result of a single check
type CheckResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Duration string `json:"duration"`
Error string `json:"error,omitempty"`
Output string `json:"output,omitempty"`
FixHint string `json:"fix_hint,omitempty"`
}
func runGoQA(cmd *cli.Command, args []string) error {
// Apply CI mode defaults
if qaCI {
qaCoverage = true
qaFailFast = true
if qaThreshold == 0 {
qaThreshold = 50
}
}
cwd, err := os.Getwd()
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
}
// Detect if this is a Go project
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
return cli.Err("not a Go project (no go.mod found)")
}
// Determine which checks to run
checkNames := determineChecks()
if !qaJSON && !qaQuiet {
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
}
// Run go mod tidy if requested
if qaMod {
if !qaQuiet {
cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...")
}
modCmd := exec.Command("go", "mod", "tidy")
modCmd.Dir = cwd
if err := modCmd.Run(); err != nil {
return cli.Wrap(err, "go mod tidy failed")
}
}
ctx, cancel := context.WithTimeout(context.Background(), qaTimeout)
defer cancel()
startTime := time.Now()
checks := buildChecks(checkNames)
results := make([]CheckResult, 0, len(checks))
passed := 0
failed := 0
for _, check := range checks {
checkStart := time.Now()
if !qaJSON && !qaQuiet {
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
}
output, err := runCheckCapture(ctx, cwd, check)
checkDuration := time.Since(checkStart)
result := CheckResult{
Name: check.Name,
Duration: checkDuration.Round(time.Millisecond).String(),
}
if err != nil {
result.Passed = false
result.Error = err.Error()
if qaVerbose {
result.Output = output
}
result.FixHint = fixHintFor(check.Name, output)
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
if qaVerbose && output != "" {
cli.Text(output)
}
if result.FixHint != "" {
cli.Hint("fix", result.FixHint)
}
}
if qaFailFast {
results = append(results, result)
break
}
} else {
result.Passed = true
if qaVerbose {
result.Output = output
}
passed++
if !qaJSON && !qaQuiet {
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
}
}
results = append(results, result)
}
// Run coverage if requested
var coverageVal *float64
var branchVal *float64
if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) {
cov, branch, err := runCoverage(ctx, cwd)
if err == nil {
coverageVal = &cov
branchVal = &branch
if !qaJSON && !qaQuiet {
cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov)
cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch)
}
if qaThreshold > 0 && cov < qaThreshold {
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
}
}
if qaBranchThreshold > 0 && branch < qaBranchThreshold {
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold)
}
}
if failed > 0 && !qaJSON && !qaQuiet {
cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.")
}
}
}
duration := time.Since(startTime).Round(time.Millisecond)
if qaJSON {
return emitQAJSON(results, coverageVal, branchVal, failed, duration)
}
return emitQASummary(passed, failed, duration)
}
func emitQAJSON(results []CheckResult, coverageVal, branchVal *float64, failed int, duration time.Duration) error {
qaResult := QAResult{
Success: failed == 0,
Duration: duration.String(),
Checks: results,
Coverage: coverageVal,
BranchCoverage: branchVal,
}
if qaThreshold > 0 {
qaResult.Threshold = &qaThreshold
}
if qaBranchThreshold > 0 {
qaResult.BranchThreshold = &qaBranchThreshold
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(qaResult)
}
func emitQASummary(passed, failed int, duration time.Duration) error {
if !qaQuiet {
cli.Blank()
if failed > 0 {
cli.Print("%s %s, %s (%s)\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
duration)
} else {
cli.Print("%s %s (%s)\n",
cli.SuccessStyle.Render(cli.Glyph(":check:")),
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
duration)
}
}
if failed > 0 {
return cli.Err("QA checks failed: %d passed, %d failed", passed, failed)
}
return nil
}
func determineChecks() []string {
// If --only is specified, use those
if qaOnly != "" {
return strings.Split(qaOnly, ",")
}
// Default checks
checks := []string{"fmt", "lint", "test", "fuzz", "docblock"}
// Add race if requested
if qaRace {
// Replace test with race (which includes test)
for i, c := range checks {
if c == "test" {
checks[i] = "race"
break
}
}
}
// Add bench if requested
if qaBench {
checks = append(checks, "bench")
}
// Remove skipped checks
if qaSkip != "" {
skipMap := make(map[string]bool)
for _, s := range strings.Split(qaSkip, ",") {
skipMap[strings.TrimSpace(s)] = true
}
filtered := make([]string, 0, len(checks))
for _, c := range checks {
if !skipMap[c] {
filtered = append(filtered, c)
}
}
checks = filtered
}
return checks
}
// QACheck represents a single QA check.
type QACheck struct {
Name string
Command string
Args []string
}
func buildChecks(names []string) []QACheck {
var checks []QACheck
for _, name := range names {
name = strings.TrimSpace(name)
check := buildCheck(name)
if check.Command != "" {
checks = append(checks, check)
}
}
return checks
}
func buildCheck(name string) QACheck {
switch name {
case "fmt", "format":
args := []string{"-l", "."}
if qaFix {
args = []string{"-w", "."}
}
return QACheck{Name: "format", Command: "gofmt", Args: args}
case "vet":
return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}}
case "lint":
args := []string{"run"}
if qaFix {
args = append(args, "--fix")
}
if qaChanged && !qaAll {
args = append(args, "--new-from-rev=HEAD")
}
args = append(args, "./...")
return QACheck{Name: "lint", Command: "golangci-lint", Args: args}
case "test":
args := []string{"test"}
if qaShort {
args = append(args, "-short")
}
if qaVerbose {
args = append(args, "-v")
}
args = append(args, "./...")
return QACheck{Name: "test", Command: "go", Args: args}
case "race":
args := []string{"test", "-race"}
if qaShort {
args = append(args, "-short")
}
if qaVerbose {
args = append(args, "-v")
}
args = append(args, "./...")
return QACheck{Name: "race", Command: "go", Args: args}
case "bench":
args := []string{"test", "-bench=.", "-benchmem", "-run=^$"}
args = append(args, "./...")
return QACheck{Name: "bench", Command: "go", Args: args}
case "vuln":
return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}}
case "sec":
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
case "fuzz":
return QACheck{Name: "fuzz", Command: "_internal_"}
case "docblock":
// Special internal check - handled separately
return QACheck{Name: "docblock", Command: "_internal_"}
default:
return QACheck{}
}
}
// fixHintFor returns an actionable fix instruction for a given check failure.
func fixHintFor(checkName, output string) string {
switch checkName {
case "format", "fmt":
return "Run 'core go qa fmt --fix' to auto-format."
case "vet":
return "Fix the issues reported by go vet — typically genuine bugs."
case "lint":
return "Run 'core go qa lint --fix' for auto-fixable issues."
case "test":
if name := extractFailingTest(output); name != "" {
return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name)
}
return "Run 'go test -run <TestName> -v ./path/' to debug."
case "race":
return "Data race detected. Add mutex, channel, or atomic to synchronise shared state."
case "bench":
return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce."
case "vuln":
return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'."
case "sec":
return "Review gosec findings. Common fixes: validate inputs, parameterised queries."
case "fuzz":
return "Add a regression test for the crashing input in testdata/fuzz/<Target>/."
case "docblock":
return "Add doc comments to exported symbols: '// Name does X.' before each declaration."
default:
return ""
}
}
var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`)
// extractFailingTest parses the first failing test name from go test output.
func extractFailingTest(output string) string {
if m := failTestRe.FindStringSubmatch(output); len(m) > 1 {
return m[1]
}
return ""
}
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
// Handle internal checks
if check.Command == "_internal_" {
return runInternalCheck(check)
}
// Check if command exists
if _, err := exec.LookPath(check.Command); err != nil {
return "", cli.Err("%s: not installed", check.Command)
}
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
cmd.Dir = dir
// For gofmt -l, capture output to check if files need formatting
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
output, err := cmd.Output()
if err != nil {
return string(output), err
}
if len(output) > 0 {
// Show files that need formatting
if !qaQuiet && !qaJSON {
cli.Text(string(output))
}
return string(output), cli.Err("files need formatting (use --fix)")
}
return "", nil
}
// For other commands, stream or capture based on quiet mode
if qaQuiet || qaJSON {
output, err := cmd.CombinedOutput()
return string(output), err
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return "", cmd.Run()
}
func runCoverage(ctx context.Context, dir string) (float64, float64, error) {
// Create temp file for coverage data
covFile, err := os.CreateTemp("", "coverage-*.out")
if err != nil {
return 0, 0, err
}
covPath := covFile.Name()
_ = covFile.Close()
defer os.Remove(covPath)
args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath}
if qaShort {
args = append(args, "-short")
}
args = append(args, "./...")
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
if !qaQuiet && !qaJSON {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Run(); err != nil {
return 0, 0, err
}
// Parse statement coverage
coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath)
output, err := coverCmd.Output()
if err != nil {
return 0, 0, err
}
// Parse last line for total coverage
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var statementPct float64
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
fields := strings.Fields(lastLine)
if len(fields) >= 3 {
// Parse percentage (e.g., "45.6%")
pctStr := strings.TrimSuffix(fields[len(fields)-1], "%")
_, _ = fmt.Sscanf(pctStr, "%f", &statementPct)
}
}
// Parse branch coverage
branchPct, err := calculateBlockCoverage(covPath)
if err != nil {
return statementPct, 0, err
}
return statementPct, branchPct, nil
}
// runInternalCheck runs internal Go-based checks (not external commands).
func runInternalCheck(check QACheck) (string, error) {
switch check.Name {
case "fuzz":
// Short burst fuzz in QA (3s per target)
duration := 3 * time.Second
if qaTimeout > 0 && qaTimeout < 30*time.Second {
duration = 2 * time.Second
}
return "", runGoFuzz(duration, "", "", qaVerbose)
case "docblock":
result, err := qa.CheckDocblockCoverage([]string{"./..."})
if err != nil {
return "", err
}
if result.Coverage < qaDocblockThreshold {
return "", cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, qaDocblockThreshold)
}
return fmt.Sprintf("docblock coverage: %.1f%% (%d/%d)", result.Coverage, result.Documented, result.Total), nil
default:
return "", cli.Err("unknown internal check: %s", check.Name)
}
}

View file

@ -1,236 +0,0 @@
package gocmd
import (
"errors"
"os"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
var (
installVerbose bool
installNoCgo bool
)
func addGoInstallCommand(parent *cli.Command) {
installCmd := &cli.Command{
Use: "install [path]",
Short: "Install Go binary",
Long: "Install Go binary to $GOPATH/bin",
RunE: func(cmd *cli.Command, args []string) error {
// Get install path from args or default to current dir
installPath := "./..."
if len(args) > 0 {
installPath = args[0]
}
// Detect if we're in a module with cmd/ subdirectories or a root main.go
if installPath == "./..." {
if _, err := os.Stat("core.go"); err == nil {
installPath = "."
} else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 {
installPath = "./cmd/..."
} else if _, err := os.Stat("main.go"); err == nil {
installPath = "."
}
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.Progress("install"))
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), installPath)
if installNoCgo {
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("cgo")), "disabled")
}
cmdArgs := []string{"install"}
if installVerbose {
cmdArgs = append(cmdArgs, "-v")
}
cmdArgs = append(cmdArgs, installPath)
execCmd := exec.Command("go", cmdArgs...)
if installNoCgo {
execCmd.Env = append(os.Environ(), "CGO_ENABLED=0")
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.fail.install", "binary")))
return err
}
// Show where it was installed
gopath := os.Getenv("GOPATH")
if gopath == "" {
home, _ := os.UserHomeDir()
gopath = filepath.Join(home, "go")
}
binDir := filepath.Join(gopath, "bin")
cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), binDir)
return nil
},
}
installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Verbose output")
installCmd.Flags().BoolVar(&installNoCgo, "no-cgo", false, "Disable CGO")
parent.AddCommand(installCmd)
}
func addGoModCommand(parent *cli.Command) {
modCmd := &cli.Command{
Use: "mod",
Short: "Module management",
Long: "Go module management commands",
}
// tidy
tidyCmd := &cli.Command{
Use: "tidy",
Short: "Run go mod tidy",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "tidy")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// download
downloadCmd := &cli.Command{
Use: "download",
Short: "Download module dependencies",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "download")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// verify
verifyCmd := &cli.Command{
Use: "verify",
Short: "Verify module checksums",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "verify")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// graph
graphCmd := &cli.Command{
Use: "graph",
Short: "Print module dependency graph",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "graph")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
modCmd.AddCommand(tidyCmd)
modCmd.AddCommand(downloadCmd)
modCmd.AddCommand(verifyCmd)
modCmd.AddCommand(graphCmd)
parent.AddCommand(modCmd)
}
func addGoWorkCommand(parent *cli.Command) {
workCmd := &cli.Command{
Use: "work",
Short: "Workspace management",
Long: "Go workspace management commands",
}
// sync
syncCmd := &cli.Command{
Use: "sync",
Short: "Sync workspace modules",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "work", "sync")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// init
initCmd := &cli.Command{
Use: "init",
Short: "Initialise a new workspace",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "work", "init")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
return err
}
// Auto-add current module if go.mod exists
if _, err := os.Stat("go.mod"); err == nil {
execCmd = exec.Command("go", "work", "use", ".")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
}
return nil
},
}
// use
useCmd := &cli.Command{
Use: "use [modules...]",
Short: "Add modules to workspace",
RunE: func(cmd *cli.Command, args []string) error {
if len(args) == 0 {
// Auto-detect modules
modules := findGoModules(".")
if len(modules) == 0 {
return errors.New("no Go modules found")
}
for _, mod := range modules {
execCmd := exec.Command("go", "work", "use", mod)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
return err
}
cli.Print("%s %s\n", successStyle.Render(i18n.T("i18n.done.add")), mod)
}
return nil
}
cmdArgs := append([]string{"work", "use"}, args...)
execCmd := exec.Command("go", cmdArgs...)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
workCmd.AddCommand(syncCmd)
workCmd.AddCommand(initCmd)
workCmd.AddCommand(useCmd)
parent.AddCommand(workCmd)
}
func findGoModules(root string) []string {
var modules []string
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.Name() == "go.mod" && path != "go.mod" {
modules = append(modules, filepath.Dir(path))
}
return nil
})
return modules
}

View file

@ -1,240 +0,0 @@
package gocmd
import (
"os"
"testing"
"forge.lthn.ai/core/cli/pkg/cli"
"github.com/stretchr/testify/assert"
)
func TestCalculateBlockCoverage(t *testing.T) {
// Create a dummy coverage profile
content := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 1
forge.lthn.ai/core/go/pkg/foo.go:5.6,7.8 2 0
forge.lthn.ai/core/go/pkg/bar.go:10.1,12.20 10 5
`
tmpfile, err := os.CreateTemp("", "test-coverage-*.out")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
_, err = tmpfile.Write([]byte(content))
assert.NoError(t, err)
err = tmpfile.Close()
assert.NoError(t, err)
// Test calculation
// 3 blocks total, 2 covered (count > 0)
// Expect (2/3) * 100 = 66.666...
pct, err := calculateBlockCoverage(tmpfile.Name())
assert.NoError(t, err)
assert.InDelta(t, 66.67, pct, 0.01)
// Test empty file (only header)
contentEmpty := "mode: atomic\n"
tmpfileEmpty, err := os.CreateTemp("", "test-coverage-empty-*.out")
assert.NoError(t, err)
defer os.Remove(tmpfileEmpty.Name())
_, err = tmpfileEmpty.Write([]byte(contentEmpty))
assert.NoError(t, err)
err = tmpfileEmpty.Close()
assert.NoError(t, err)
pct, err = calculateBlockCoverage(tmpfileEmpty.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test non-existent file
pct, err = calculateBlockCoverage("non-existent-file")
assert.Error(t, err)
assert.Equal(t, 0.0, pct)
// Test malformed file
contentMalformed := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 notanumber
`
tmpfileMalformed, err := os.CreateTemp("", "test-coverage-malformed-*.out")
assert.NoError(t, err)
defer os.Remove(tmpfileMalformed.Name())
_, err = tmpfileMalformed.Write([]byte(contentMalformed))
assert.NoError(t, err)
err = tmpfileMalformed.Close()
assert.NoError(t, err)
pct, err = calculateBlockCoverage(tmpfileMalformed.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test malformed file - missing fields
contentMalformed2 := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
`
tmpfileMalformed2, err := os.CreateTemp("", "test-coverage-malformed2-*.out")
assert.NoError(t, err)
defer os.Remove(tmpfileMalformed2.Name())
_, err = tmpfileMalformed2.Write([]byte(contentMalformed2))
assert.NoError(t, err)
err = tmpfileMalformed2.Close()
assert.NoError(t, err)
pct, err = calculateBlockCoverage(tmpfileMalformed2.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test completely empty file
tmpfileEmpty2, err := os.CreateTemp("", "test-coverage-empty2-*.out")
assert.NoError(t, err)
defer os.Remove(tmpfileEmpty2.Name())
err = tmpfileEmpty2.Close()
assert.NoError(t, err)
pct, err = calculateBlockCoverage(tmpfileEmpty2.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
}
func TestParseOverallCoverage(t *testing.T) {
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
ok forge.lthn.ai/core/go/pkg/bar 0.200s coverage: 100.0% of statements
`
pct := parseOverallCoverage(output)
assert.Equal(t, 75.0, pct)
outputNoCov := "ok forge.lthn.ai/core/go/pkg/foo 0.100s"
pct = parseOverallCoverage(outputNoCov)
assert.Equal(t, 0.0, pct)
}
func TestFormatCoverage(t *testing.T) {
assert.Contains(t, formatCoverage(85.0), "85.0%")
assert.Contains(t, formatCoverage(65.0), "65.0%")
assert.Contains(t, formatCoverage(25.0), "25.0%")
}
func TestAddGoCovCommand(t *testing.T) {
cmd := &cli.Command{Use: "test"}
addGoCovCommand(cmd)
assert.True(t, cmd.HasSubCommands())
sub := cmd.Commands()[0]
assert.Equal(t, "cov", sub.Name())
}
func TestAddGoQACommand(t *testing.T) {
cmd := &cli.Command{Use: "test"}
addGoQACommand(cmd)
assert.True(t, cmd.HasSubCommands())
sub := cmd.Commands()[0]
assert.Equal(t, "qa", sub.Name())
}
func TestDetermineChecks(t *testing.T) {
// Default checks
qaOnly = ""
qaSkip = ""
qaRace = false
qaBench = false
checks := determineChecks()
assert.Contains(t, checks, "fmt")
assert.Contains(t, checks, "test")
// Only
qaOnly = "fmt,lint"
checks = determineChecks()
assert.Equal(t, []string{"fmt", "lint"}, checks)
// Skip
qaOnly = ""
qaSkip = "fmt,lint"
checks = determineChecks()
assert.NotContains(t, checks, "fmt")
assert.NotContains(t, checks, "lint")
assert.Contains(t, checks, "test")
// Race
qaSkip = ""
qaRace = true
checks = determineChecks()
assert.Contains(t, checks, "race")
assert.NotContains(t, checks, "test")
// Reset
qaRace = false
}
func TestBuildCheck(t *testing.T) {
qaFix = false
c := buildCheck("fmt")
assert.Equal(t, "format", c.Name)
assert.Equal(t, []string{"-l", "."}, c.Args)
qaFix = true
c = buildCheck("fmt")
assert.Equal(t, []string{"-w", "."}, c.Args)
c = buildCheck("vet")
assert.Equal(t, "vet", c.Name)
c = buildCheck("lint")
assert.Equal(t, "lint", c.Name)
c = buildCheck("test")
assert.Equal(t, "test", c.Name)
c = buildCheck("race")
assert.Equal(t, "race", c.Name)
c = buildCheck("bench")
assert.Equal(t, "bench", c.Name)
c = buildCheck("vuln")
assert.Equal(t, "vuln", c.Name)
c = buildCheck("sec")
assert.Equal(t, "sec", c.Name)
c = buildCheck("fuzz")
assert.Equal(t, "fuzz", c.Name)
c = buildCheck("docblock")
assert.Equal(t, "docblock", c.Name)
c = buildCheck("unknown")
assert.Equal(t, "", c.Name)
}
func TestBuildChecks(t *testing.T) {
checks := buildChecks([]string{"fmt", "vet", "unknown"})
assert.Equal(t, 2, len(checks))
assert.Equal(t, "format", checks[0].Name)
assert.Equal(t, "vet", checks[1].Name)
}
func TestFixHintFor(t *testing.T) {
assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix")
assert.Contains(t, fixHintFor("vet", ""), "go vet")
assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix")
assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo")
assert.Contains(t, fixHintFor("race", ""), "Data race")
assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression")
assert.Contains(t, fixHintFor("vuln", ""), "govulncheck")
assert.Contains(t, fixHintFor("sec", ""), "gosec")
assert.Contains(t, fixHintFor("fuzz", ""), "crashing input")
assert.Contains(t, fixHintFor("docblock", ""), "doc comments")
assert.Equal(t, "", fixHintFor("unknown", ""))
}
func TestRunGoQA_NoGoMod(t *testing.T) {
// runGoQA should fail if go.mod is not present in CWD
// We run it in a temp dir without go.mod
tmpDir, _ := os.MkdirTemp("", "test-qa-*")
defer os.RemoveAll(tmpDir)
cwd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(cwd)
cmd := &cli.Command{Use: "qa"}
err := runGoQA(cmd, []string{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no go.mod found")
}

View file

@ -1,55 +0,0 @@
// Package module provides CLI commands for managing marketplace modules.
//
// Commands:
// - install: Install a module from a Git repo
// - list: List installed modules
// - update: Update a module or all modules
// - remove: Remove an installed module
package module
import (
"os"
"path/filepath"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-scm/marketplace"
"forge.lthn.ai/core/go-io/store"
)
// AddModuleCommands registers the 'module' command and all subcommands.
func AddModuleCommands(root *cli.Command) {
moduleCmd := &cli.Command{
Use: "module",
Short: i18n.T("Manage marketplace modules"),
}
root.AddCommand(moduleCmd)
addInstallCommand(moduleCmd)
addListCommand(moduleCmd)
addUpdateCommand(moduleCmd)
addRemoveCommand(moduleCmd)
}
// moduleSetup returns the modules directory, store, and installer.
// The caller must defer st.Close().
func moduleSetup() (string, *store.Store, *marketplace.Installer, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", nil, nil, cli.Wrap(err, "failed to determine home directory")
}
modulesDir := filepath.Join(home, ".core", "modules")
if err := os.MkdirAll(modulesDir, 0755); err != nil {
return "", nil, nil, cli.Wrap(err, "failed to create modules directory")
}
dbPath := filepath.Join(modulesDir, "modules.db")
st, err := store.New(dbPath)
if err != nil {
return "", nil, nil, cli.Wrap(err, "failed to open module store")
}
inst := marketplace.NewInstaller(modulesDir, st)
return modulesDir, st, inst, nil
}

View file

@ -1,59 +0,0 @@
package module
import (
"context"
"errors"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-scm/marketplace"
)
var (
installRepo string
installSignKey string
)
func addInstallCommand(parent *cli.Command) {
installCmd := cli.NewCommand(
"install <code>",
i18n.T("Install a module from a Git repo"),
i18n.T("Install a module by cloning its Git repository, verifying the manifest signature, and registering it.\n\nThe --repo flag is required and specifies the Git URL to clone from."),
func(cmd *cli.Command, args []string) error {
if installRepo == "" {
return errors.New("--repo flag is required")
}
return runInstall(args[0], installRepo, installSignKey)
},
)
installCmd.Args = cli.ExactArgs(1)
installCmd.Example = " core module install my-module --repo https://forge.lthn.ai/modules/my-module.git\n core module install signed-mod --repo ssh://git@forge.lthn.ai:2223/modules/signed.git --sign-key abc123"
cli.StringFlag(installCmd, &installRepo, "repo", "r", "", i18n.T("Git repository URL to clone"))
cli.StringFlag(installCmd, &installSignKey, "sign-key", "k", "", i18n.T("Hex-encoded ed25519 public key for manifest verification"))
parent.AddCommand(installCmd)
}
func runInstall(code, repo, signKey string) error {
_, st, inst, err := moduleSetup()
if err != nil {
return err
}
defer st.Close()
cli.Dim("Installing module " + code + " from " + repo + "...")
mod := marketplace.Module{
Code: code,
Repo: repo,
SignKey: signKey,
}
if err := inst.Install(context.Background(), mod); err != nil {
return err
}
cli.Success("Module " + code + " installed successfully")
return nil
}

View file

@ -1,51 +0,0 @@
package module
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
func addListCommand(parent *cli.Command) {
listCmd := cli.NewCommand(
"list",
i18n.T("List installed modules"),
"",
func(cmd *cli.Command, args []string) error {
return runList()
},
)
parent.AddCommand(listCmd)
}
func runList() error {
_, st, inst, err := moduleSetup()
if err != nil {
return err
}
defer st.Close()
installed, err := inst.Installed()
if err != nil {
return err
}
if len(installed) == 0 {
cli.Dim("No modules installed")
return nil
}
table := cli.NewTable("Code", "Name", "Version", "Repo")
for _, m := range installed {
table.AddRow(m.Code, m.Name, m.Version, m.Repo)
}
fmt.Println()
table.Render()
fmt.Println()
cli.Dim(fmt.Sprintf("%d module(s) installed", len(installed)))
return nil
}

View file

@ -1,40 +0,0 @@
package module
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
func addRemoveCommand(parent *cli.Command) {
removeCmd := cli.NewCommand(
"remove <code>",
i18n.T("Remove an installed module"),
"",
func(cmd *cli.Command, args []string) error {
return runRemove(args[0])
},
)
removeCmd.Args = cli.ExactArgs(1)
parent.AddCommand(removeCmd)
}
func runRemove(code string) error {
_, st, inst, err := moduleSetup()
if err != nil {
return err
}
defer st.Close()
if !cli.Confirm("Remove module " + code + "?") {
cli.Dim("Cancelled")
return nil
}
if err := inst.Remove(code); err != nil {
return err
}
cli.Success("Module " + code + " removed")
return nil
}

View file

@ -1,85 +0,0 @@
package module
import (
"context"
"errors"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
var updateAll bool
func addUpdateCommand(parent *cli.Command) {
updateCmd := cli.NewCommand(
"update [code]",
i18n.T("Update a module or all modules"),
i18n.T("Update a specific module to the latest version, or use --all to update all installed modules."),
func(cmd *cli.Command, args []string) error {
if updateAll {
return runUpdateAll()
}
if len(args) == 0 {
return errors.New("module code required (or use --all)")
}
return runUpdate(args[0])
},
)
cli.BoolFlag(updateCmd, &updateAll, "all", "a", false, i18n.T("Update all installed modules"))
parent.AddCommand(updateCmd)
}
func runUpdate(code string) error {
_, st, inst, err := moduleSetup()
if err != nil {
return err
}
defer st.Close()
cli.Dim("Updating " + code + "...")
if err := inst.Update(context.Background(), code); err != nil {
return err
}
cli.Success("Module " + code + " updated successfully")
return nil
}
func runUpdateAll() error {
_, st, inst, err := moduleSetup()
if err != nil {
return err
}
defer st.Close()
installed, err := inst.Installed()
if err != nil {
return err
}
if len(installed) == 0 {
cli.Dim("No modules installed")
return nil
}
ctx := context.Background()
var updated, failed int
for _, m := range installed {
cli.Dim("Updating " + m.Code + "...")
if err := inst.Update(ctx, m.Code); err != nil {
cli.Errorf("Failed to update %s: %v", m.Code, err)
failed++
continue
}
cli.Success(m.Code + " updated")
updated++
}
fmt.Println()
cli.Dim(fmt.Sprintf("%d updated, %d failed", updated, failed))
return nil
}

View file

@ -1,158 +0,0 @@
package pkgcmd
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
var (
installTargetDir string
installAddToReg bool
)
// addPkgInstallCommand adds the 'pkg install' command.
func addPkgInstallCommand(parent *cobra.Command) {
installCmd := &cobra.Command{
Use: "install <org/repo>",
Short: i18n.T("cmd.pkg.install.short"),
Long: i18n.T("cmd.pkg.install.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgInstall(args[0], installTargetDir, installAddToReg)
},
}
installCmd.Flags().StringVar(&installTargetDir, "dir", "", i18n.T("cmd.pkg.install.flag.dir"))
installCmd.Flags().BoolVar(&installAddToReg, "add", false, i18n.T("cmd.pkg.install.flag.add"))
parent.AddCommand(installCmd)
}
func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
ctx := context.Background()
// Parse org/repo
parts := strings.Split(repoArg, "/")
if len(parts) != 2 {
return errors.New(i18n.T("cmd.pkg.error.invalid_repo_format"))
}
org, repoName := parts[0], parts[1]
// Determine target directory
if targetDir == "" {
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
if reg, err := repos.LoadRegistry(coreio.Local, regPath); err == nil {
targetDir = reg.BasePath
if targetDir == "" {
targetDir = "./packages"
}
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(regPath), targetDir)
}
}
}
if targetDir == "" {
targetDir = "."
}
}
if strings.HasPrefix(targetDir, "~/") {
home, _ := os.UserHomeDir()
targetDir = filepath.Join(home, targetDir[2:])
}
repoPath := filepath.Join(targetDir, repoName)
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
return nil
}
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err)
}
fmt.Printf("%s %s/%s\n", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), repoPath)
fmt.Println()
fmt.Printf(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
err := gitClone(ctx, org, repoName, repoPath)
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error()))
return err
}
fmt.Printf("%s\n", successStyle.Render("✓"))
if addToRegistry {
if err := addToRegistryFile(org, repoName); err != nil {
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
} else {
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
}
}
fmt.Println()
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
return nil
}
func addToRegistryFile(org, repoName string) error {
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return err
}
if _, exists := reg.Get(repoName); exists {
return nil
}
content, err := coreio.Local.Read(regPath)
if err != nil {
return err
}
repoType := detectRepoType(repoName)
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType)
content += entry
return coreio.Local.Write(regPath, content)
}
func detectRepoType(name string) string {
lower := strings.ToLower(name)
if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") {
return "module"
}
if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") {
return "plugin"
}
if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") {
return "service"
}
if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") {
return "website"
}
if strings.HasPrefix(lower, "core-") {
return "package"
}
return "package"
}

View file

@ -1,256 +0,0 @@
package pkgcmd
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
// addPkgListCommand adds the 'pkg list' command.
func addPkgListCommand(parent *cobra.Command) {
listCmd := &cobra.Command{
Use: "list",
Short: i18n.T("cmd.pkg.list.short"),
Long: i18n.T("cmd.pkg.list.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runPkgList()
},
}
parent.AddCommand(listCmd)
}
func runPkgList() error {
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
allRepos := reg.List()
if len(allRepos) == 0 {
fmt.Println(i18n.T("cmd.pkg.list.no_packages"))
return nil
}
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
var installed, missing int
for _, r := range allRepos {
repoPath := filepath.Join(basePath, r.Name)
exists := coreio.Local.Exists(filepath.Join(repoPath, ".git"))
if exists {
installed++
} else {
missing++
}
status := successStyle.Render("✓")
if !exists {
status = dimStyle.Render("○")
}
desc := r.Description
if len(desc) > 40 {
desc = desc[:37] + "..."
}
if desc == "" {
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name))
fmt.Printf(" %s\n", desc)
}
fmt.Println()
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
if missing > 0 {
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
}
return nil
}
var updateAll bool
// addPkgUpdateCommand adds the 'pkg update' command.
func addPkgUpdateCommand(parent *cobra.Command) {
updateCmd := &cobra.Command{
Use: "update [packages...]",
Short: i18n.T("cmd.pkg.update.short"),
Long: i18n.T("cmd.pkg.update.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if !updateAll && len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.specify_package"))
}
return runPkgUpdate(args, updateAll)
},
}
updateCmd.Flags().BoolVar(&updateAll, "all", false, i18n.T("cmd.pkg.update.flag.all"))
parent.AddCommand(updateCmd)
}
func runPkgUpdate(packages []string, all bool) error {
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
var toUpdate []string
if all {
for _, r := range reg.List() {
toUpdate = append(toUpdate, r.Name)
}
} else {
toUpdate = packages
}
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
var updated, skipped, failed int
for _, name := range toUpdate {
repoPath := filepath.Join(basePath, name)
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
skipped++
continue
}
fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name)
cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗"))
fmt.Printf(" %s\n", strings.TrimSpace(string(output)))
failed++
continue
}
if strings.Contains(string(output), "Already up to date") {
fmt.Printf("%s\n", dimStyle.Render(i18n.T("common.status.up_to_date")))
} else {
fmt.Printf("%s\n", successStyle.Render("✓"))
}
updated++
}
fmt.Println()
fmt.Printf("%s %s\n",
dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed}))
return nil
}
// addPkgOutdatedCommand adds the 'pkg outdated' command.
func addPkgOutdatedCommand(parent *cobra.Command) {
outdatedCmd := &cobra.Command{
Use: "outdated",
Short: i18n.T("cmd.pkg.outdated.short"),
Long: i18n.T("cmd.pkg.outdated.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runPkgOutdated()
},
}
parent.AddCommand(outdatedCmd)
}
func runPkgOutdated() error {
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
var outdated, upToDate, notInstalled int
for _, r := range reg.List() {
repoPath := filepath.Join(basePath, r.Name)
if !coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
notInstalled++
continue
}
// Fetch updates
_ = exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run()
// Check if behind
cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
output, err := cmd.Output()
if err != nil {
continue
}
count := strings.TrimSpace(string(output))
if count != "0" {
fmt.Printf(" %s %s (%s)\n",
errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count}))
outdated++
} else {
upToDate++
}
}
fmt.Println()
if outdated == 0 {
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
} else {
fmt.Printf("%s %s\n",
dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate}))
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
}
return nil
}

View file

@ -1,144 +0,0 @@
// cmd_remove.go implements the 'pkg remove' command with safety checks.
//
// Before removing a package, it verifies:
// 1. No uncommitted changes exist
// 2. No unpushed branches exist
// This prevents accidental data loss from agents or tools that might
// attempt to remove packages without cleaning up first.
package pkgcmd
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
var removeForce bool
func addPkgRemoveCommand(parent *cobra.Command) {
removeCmd := &cobra.Command{
Use: "remove <package>",
Short: "Remove a package (with safety checks)",
Long: `Removes a package directory after verifying it has no uncommitted
changes or unpushed branches. Use --force to skip safety checks.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgRemove(args[0], removeForce)
},
}
removeCmd.Flags().BoolVar(&removeForce, "force", false, "Skip safety checks (dangerous)")
parent.AddCommand(removeCmd)
}
func runPkgRemove(name string, force bool) error {
// Find package path via registry
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
repoPath := filepath.Join(basePath, name)
if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
return fmt.Errorf("package %s is not installed at %s", name, repoPath)
}
if !force {
blocked, reasons := checkRepoSafety(repoPath)
if blocked {
fmt.Printf("%s Cannot remove %s:\n", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
for _, r := range reasons {
fmt.Printf(" %s %s\n", errorStyle.Render("·"), r)
}
fmt.Printf("\nResolve the issues above or use --force to override.\n")
return errors.New("package has unresolved changes")
}
}
// Remove the directory
fmt.Printf("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
if err := coreio.Local.DeleteAll(repoPath); err != nil {
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
return err
}
fmt.Printf("%s\n", successStyle.Render("ok"))
return nil
}
// checkRepoSafety checks a git repo for uncommitted changes and unpushed branches.
func checkRepoSafety(repoPath string) (blocked bool, reasons []string) {
// Check for uncommitted changes (staged, unstaged, untracked)
cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain")
output, err := cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d uncommitted changes", len(lines)))
}
// Check for unpushed commits on current branch
cmd = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
output, err = cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d unpushed commits on current branch", len(lines)))
}
// Check all local branches for unpushed work
cmd = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
output, _ = cmd.Output()
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
branches := strings.Split(trimmed, "\n")
var unmerged []string
for _, b := range branches {
b = strings.TrimSpace(b)
b = strings.TrimPrefix(b, "* ")
if b != "" {
unmerged = append(unmerged, b)
}
}
if len(unmerged) > 0 {
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d unmerged branches: %s",
len(unmerged), strings.Join(unmerged, ", ")))
}
}
// Check for stashed changes
cmd = exec.Command("git", "-C", repoPath, "stash", "list")
output, err = cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
blocked = true
reasons = append(reasons, fmt.Sprintf("has %d stashed entries", len(lines)))
}
return blocked, reasons
}

View file

@ -1,92 +0,0 @@
package pkgcmd
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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))
}
return repoPath
}
func TestCheckRepoSafety_Clean(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "clean-repo")
blocked, reasons := checkRepoSafety(repoPath)
assert.False(t, blocked)
assert.Empty(t, reasons)
}
func TestCheckRepoSafety_UncommittedChanges(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "dirty-repo")
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "new.txt"), []byte("data"), 0644))
blocked, reasons := checkRepoSafety(repoPath)
assert.True(t, blocked)
assert.NotEmpty(t, reasons)
assert.Contains(t, reasons[0], "uncommitted changes")
}
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())
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
}
}
}
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 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
}

View file

@ -1,206 +0,0 @@
package pkgcmd
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"forge.lthn.ai/core/go-cache"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
var (
searchOrg string
searchPattern string
searchType string
searchLimit int
searchRefresh bool
)
// addPkgSearchCommand adds the 'pkg search' command.
func addPkgSearchCommand(parent *cobra.Command) {
searchCmd := &cobra.Command{
Use: "search",
Short: i18n.T("cmd.pkg.search.short"),
Long: i18n.T("cmd.pkg.search.long"),
RunE: func(cmd *cobra.Command, args []string) error {
org := searchOrg
pattern := searchPattern
limit := searchLimit
if org == "" {
org = "host-uk"
}
if pattern == "" {
pattern = "*"
}
if limit == 0 {
limit = 50
}
return runPkgSearch(org, pattern, searchType, limit, searchRefresh)
},
}
searchCmd.Flags().StringVar(&searchOrg, "org", "", i18n.T("cmd.pkg.search.flag.org"))
searchCmd.Flags().StringVar(&searchPattern, "pattern", "", i18n.T("cmd.pkg.search.flag.pattern"))
searchCmd.Flags().StringVar(&searchType, "type", "", i18n.T("cmd.pkg.search.flag.type"))
searchCmd.Flags().IntVar(&searchLimit, "limit", 0, i18n.T("cmd.pkg.search.flag.limit"))
searchCmd.Flags().BoolVar(&searchRefresh, "refresh", false, i18n.T("cmd.pkg.search.flag.refresh"))
parent.AddCommand(searchCmd)
}
type ghRepo struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
UpdatedAt string `json:"updated_at"`
Language string `json:"language"`
}
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
// Initialize cache in workspace .core/ directory
var cacheDir string
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
}
c, err := cache.New(coreio.Local, cacheDir, 0)
if err != nil {
c = nil
}
cacheKey := cache.GitHubReposKey(org)
var ghRepos []ghRepo
var fromCache bool
// Try cache first (unless refresh requested)
if c != nil && !refresh {
if found, err := c.Get(cacheKey, &ghRepos); found && err == nil {
fromCache = true
age := c.Age(cacheKey)
fmt.Printf("%s %s %s\n", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
}
}
// Fetch from GitHub if not cached
if !fromCache {
if !ghAuthenticated() {
return errors.New(i18n.T("cmd.pkg.error.gh_not_authenticated"))
}
if os.Getenv("GH_TOKEN") != "" {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
fmt.Printf("%s %s\n\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
}
fmt.Printf("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
cmd := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", fmt.Sprintf("%d", limit))
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println()
errStr := strings.TrimSpace(string(output))
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
return errors.New(i18n.T("cmd.pkg.error.auth_failed"))
}
return fmt.Errorf("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errStr)
}
if err := json.Unmarshal(output, &ghRepos); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.parse", "results"), err)
}
if c != nil {
_ = c.Set(cacheKey, ghRepos)
}
fmt.Printf("%s\n", successStyle.Render("✓"))
}
// Filter by glob pattern and type
var filtered []ghRepo
for _, r := range ghRepos {
if !matchGlob(pattern, r.Name) {
continue
}
if repoType != "" && !strings.Contains(r.Name, repoType) {
continue
}
filtered = append(filtered, r)
}
if len(filtered) == 0 {
fmt.Println(i18n.T("cmd.pkg.search.no_repos_found"))
return nil
}
slices.SortFunc(filtered, func(a, b ghRepo) int {
return cmp.Compare(a.Name, b.Name)
})
fmt.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
for _, r := range filtered {
visibility := ""
if r.Visibility == "private" {
visibility = dimStyle.Render(" " + i18n.T("cmd.pkg.search.private_label"))
}
desc := r.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
if desc == "" {
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility)
fmt.Printf(" %s\n", desc)
}
fmt.Println()
fmt.Printf("%s %s\n", i18n.T("common.hint.install_with"), dimStyle.Render(fmt.Sprintf("core pkg install %s/<repo-name>", org)))
return nil
}
// matchGlob does simple glob matching with * wildcards
func matchGlob(pattern, name string) bool {
if pattern == "*" || pattern == "" {
return true
}
parts := strings.Split(pattern, "*")
pos := 0
for i, part := range parts {
if part == "" {
continue
}
idx := strings.Index(name[pos:], part)
if idx == -1 {
return false
}
if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 {
return false
}
pos += idx + len(part)
}
if !strings.HasSuffix(pattern, "*") && pos != len(name) {
return false
}
return true
}

View file

@ -1,29 +0,0 @@
// Package plugin provides CLI commands for managing core plugins.
//
// Commands:
// - install: Install a plugin from GitHub
// - list: List installed plugins
// - info: Show detailed plugin information
// - update: Update a plugin or all plugins
// - remove: Remove an installed plugin
package plugin
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
// AddPluginCommands registers the 'plugin' command and all subcommands.
func AddPluginCommands(root *cli.Command) {
pluginCmd := &cli.Command{
Use: "plugin",
Short: i18n.T("Manage plugins"),
}
root.AddCommand(pluginCmd)
addInstallCommand(pluginCmd)
addListCommand(pluginCmd)
addInfoCommand(pluginCmd)
addUpdateCommand(pluginCmd)
addRemoveCommand(pluginCmd)
}

View file

@ -1,86 +0,0 @@
package plugin
import (
"fmt"
"path/filepath"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/plugin"
)
func addInfoCommand(parent *cli.Command) {
infoCmd := cli.NewCommand(
"info <name>",
i18n.T("Show detailed plugin information"),
"",
func(cmd *cli.Command, args []string) error {
return runInfo(args[0])
},
)
infoCmd.Args = cli.ExactArgs(1)
parent.AddCommand(infoCmd)
}
func runInfo(name string) error {
basePath, err := pluginBasePath()
if err != nil {
return err
}
registry := plugin.NewRegistry(io.Local, basePath)
if err := registry.Load(); err != nil {
return err
}
cfg, ok := registry.Get(name)
if !ok {
return fmt.Errorf("plugin not found: %s", name)
}
// Try to load the manifest for extended information
loader := plugin.NewLoader(io.Local, basePath)
manifest, manifestErr := loader.LoadPlugin(name)
fmt.Println()
cli.Label("Name", cfg.Name)
cli.Label("Version", cfg.Version)
cli.Label("Source", cfg.Source)
status := "disabled"
if cfg.Enabled {
status = "enabled"
}
cli.Label("Status", status)
cli.Label("Installed", cfg.InstalledAt)
cli.Label("Path", filepath.Join(basePath, name))
if manifestErr == nil && manifest != nil {
if manifest.Description != "" {
cli.Label("Description", manifest.Description)
}
if manifest.Author != "" {
cli.Label("Author", manifest.Author)
}
if manifest.Entrypoint != "" {
cli.Label("Entrypoint", manifest.Entrypoint)
}
if manifest.MinVersion != "" {
cli.Label("Min Version", manifest.MinVersion)
}
if len(manifest.Dependencies) > 0 {
for i, dep := range manifest.Dependencies {
if i == 0 {
cli.Label("Dependencies", dep)
} else {
fmt.Printf(" %s\n", dep)
}
}
}
}
fmt.Println()
return nil
}

View file

@ -1,61 +0,0 @@
package plugin
import (
"context"
"os"
"path/filepath"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/plugin"
)
func addInstallCommand(parent *cli.Command) {
installCmd := cli.NewCommand(
"install <source>",
i18n.T("Install a plugin from GitHub"),
i18n.T("Install a plugin from a GitHub repository.\n\nSource format: org/repo or org/repo@version"),
func(cmd *cli.Command, args []string) error {
return runInstall(args[0])
},
)
installCmd.Args = cli.ExactArgs(1)
installCmd.Example = " core plugin install host-uk/core-plugin-example\n core plugin install host-uk/core-plugin-example@v1.0.0"
parent.AddCommand(installCmd)
}
func runInstall(source string) error {
basePath, err := pluginBasePath()
if err != nil {
return err
}
registry := plugin.NewRegistry(io.Local, basePath)
if err := registry.Load(); err != nil {
return err
}
installer := plugin.NewInstaller(io.Local, registry)
cli.Dim("Installing plugin from " + source + "...")
if err := installer.Install(context.Background(), source); err != nil {
return err
}
_, repo, _, _ := plugin.ParseSource(source)
cli.Success("Plugin " + repo + " installed successfully")
return nil
}
// pluginBasePath returns the default plugin directory (~/.core/plugins/).
func pluginBasePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", cli.Wrap(err, "failed to determine home directory")
}
return filepath.Join(home, ".core", "plugins"), nil
}

View file

@ -1,57 +0,0 @@
package plugin
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/plugin"
)
func addListCommand(parent *cli.Command) {
listCmd := cli.NewCommand(
"list",
i18n.T("List installed plugins"),
"",
func(cmd *cli.Command, args []string) error {
return runList()
},
)
parent.AddCommand(listCmd)
}
func runList() error {
basePath, err := pluginBasePath()
if err != nil {
return err
}
registry := plugin.NewRegistry(io.Local, basePath)
if err := registry.Load(); err != nil {
return err
}
plugins := registry.List()
if len(plugins) == 0 {
cli.Dim("No plugins installed")
return nil
}
table := cli.NewTable("Name", "Version", "Source", "Status")
for _, p := range plugins {
status := "disabled"
if p.Enabled {
status = "enabled"
}
table.AddRow(p.Name, p.Version, p.Source, status)
}
fmt.Println()
table.Render()
fmt.Println()
cli.Dim(fmt.Sprintf("%d plugin(s) installed", len(plugins)))
return nil
}

View file

@ -1,48 +0,0 @@
package plugin
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/plugin"
)
func addRemoveCommand(parent *cli.Command) {
removeCmd := cli.NewCommand(
"remove <name>",
i18n.T("Remove an installed plugin"),
"",
func(cmd *cli.Command, args []string) error {
return runRemove(args[0])
},
)
removeCmd.Args = cli.ExactArgs(1)
parent.AddCommand(removeCmd)
}
func runRemove(name string) error {
basePath, err := pluginBasePath()
if err != nil {
return err
}
registry := plugin.NewRegistry(io.Local, basePath)
if err := registry.Load(); err != nil {
return err
}
if !cli.Confirm("Remove plugin " + name + "?") {
cli.Dim("Cancelled")
return nil
}
installer := plugin.NewInstaller(io.Local, registry)
if err := installer.Remove(name); err != nil {
return err
}
cli.Success("Plugin " + name + " removed")
return nil
}

View file

@ -1,95 +0,0 @@
package plugin
import (
"context"
"errors"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/plugin"
)
var updateAll bool
func addUpdateCommand(parent *cli.Command) {
updateCmd := cli.NewCommand(
"update [name]",
i18n.T("Update a plugin or all plugins"),
i18n.T("Update a specific plugin to the latest version, or use --all to update all installed plugins."),
func(cmd *cli.Command, args []string) error {
if updateAll {
return runUpdateAll()
}
if len(args) == 0 {
return errors.New("plugin name required (or use --all)")
}
return runUpdate(args[0])
},
)
cli.BoolFlag(updateCmd, &updateAll, "all", "a", false, i18n.T("Update all installed plugins"))
parent.AddCommand(updateCmd)
}
func runUpdate(name string) error {
basePath, err := pluginBasePath()
if err != nil {
return err
}
registry := plugin.NewRegistry(io.Local, basePath)
if err := registry.Load(); err != nil {
return err
}
installer := plugin.NewInstaller(io.Local, registry)
cli.Dim("Updating " + name + "...")
if err := installer.Update(context.Background(), name); err != nil {
return err
}
cli.Success("Plugin " + name + " updated successfully")
return nil
}
func runUpdateAll() error {
basePath, err := pluginBasePath()
if err != nil {
return err
}
registry := plugin.NewRegistry(io.Local, basePath)
if err := registry.Load(); err != nil {
return err
}
plugins := registry.List()
if len(plugins) == 0 {
cli.Dim("No plugins installed")
return nil
}
installer := plugin.NewInstaller(io.Local, registry)
ctx := context.Background()
var updated, failed int
for _, p := range plugins {
cli.Dim("Updating " + p.Name + "...")
if err := installer.Update(ctx, p.Name); err != nil {
cli.Errorf("Failed to update %s: %v", p.Name, err)
failed++
continue
}
cli.Success(p.Name + " updated")
updated++
}
fmt.Println()
cli.Dim(fmt.Sprintf("%d updated, %d failed", updated, failed))
return nil
}

View file

@ -1,274 +0,0 @@
package service
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-process"
"forge.lthn.ai/core/go-scm/manifest"
)
// AddServiceCommands registers core start/stop/list/restart as top-level commands.
func AddServiceCommands(root *cli.Command) {
startCmd := cli.NewCommand("start", "Start a project daemon",
"Reads .core/manifest.yaml and starts the named daemon (or the default).\n"+
"The daemon runs detached in the background.",
func(cmd *cli.Command, args []string) error {
return runStart(args)
},
)
stopCmd := cli.NewCommand("stop", "Stop a project daemon",
"Stops the named daemon for the current project, or all daemons if no name given.",
func(cmd *cli.Command, args []string) error {
return runStop(args)
},
)
listCmd := cli.NewCommand("list", "List running daemons",
"Shows all running daemons tracked in ~/.core/daemons/.",
func(cmd *cli.Command, args []string) error {
return runList()
},
)
restartCmd := cli.NewCommand("restart", "Restart a project daemon",
"Stops then starts the named daemon.",
func(cmd *cli.Command, args []string) error {
if err := runStop(args); err != nil {
return err
}
return runStart(args)
},
)
root.AddCommand(startCmd, stopCmd, listCmd, restartCmd)
}
func runStart(args []string) error {
m, projectDir, err := findManifest()
if err != nil {
return err
}
daemonName, spec, err := resolveDaemon(m, args)
if err != nil {
return err
}
reg := process.DefaultRegistry()
// Check if already running.
if _, ok := reg.Get(m.Code, daemonName); ok {
return fmt.Errorf("%s/%s is already running", m.Code, daemonName)
}
// Resolve binary.
binary := spec.Binary
if binary == "" {
return fmt.Errorf("daemon %q has no binary specified", daemonName)
}
binPath, err := exec.LookPath(binary)
if err != nil {
return fmt.Errorf("binary %q not found in PATH: %w", binary, err)
}
// Launch detached.
cmd := exec.Command(binPath, spec.Args...)
cmd.Dir = projectDir
cmd.Env = append(os.Environ(), "CORE_DAEMON=1")
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start %s: %w", daemonName, err)
}
pid := cmd.Process.Pid
_ = cmd.Process.Release()
// Wait for health if configured.
health := spec.Health
if health != "" && health != "127.0.0.1:0" {
if process.WaitForHealth(health, 5000) {
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d, health %s)", m.Code, daemonName, pid, health))
} else {
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d, health not yet ready)", m.Code, daemonName, pid))
}
} else {
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d)", m.Code, daemonName, pid))
}
// Register in the daemon registry.
if err := reg.Register(process.DaemonEntry{
Code: m.Code,
Daemon: daemonName,
PID: pid,
Health: health,
Project: projectDir,
Binary: binPath,
}); err != nil {
cli.LogWarn(fmt.Sprintf("Daemon started but registry failed: %v", err))
}
return nil
}
func runStop(args []string) error {
reg := process.DefaultRegistry()
m, _, err := findManifest()
if err != nil {
return err
}
// If a specific daemon name was given, stop only that one.
if len(args) > 0 {
return stopDaemon(reg, m.Code, args[0])
}
// No args: stop all daemons for this project.
entries, err := reg.List()
if err != nil {
return err
}
stopped := 0
for _, e := range entries {
if e.Code == m.Code {
if err := stopDaemon(reg, e.Code, e.Daemon); err != nil {
cli.LogError(fmt.Sprintf("Failed to stop %s/%s: %v", e.Code, e.Daemon, err))
} else {
stopped++
}
}
}
if stopped == 0 {
cli.LogInfo("No running daemons for " + m.Code)
}
return nil
}
func stopDaemon(reg *process.Registry, code, daemon string) error {
entry, ok := reg.Get(code, daemon)
if !ok {
return fmt.Errorf("%s/%s is not running", code, daemon)
}
proc, err := os.FindProcess(entry.PID)
if err != nil {
return fmt.Errorf("process %d not found: %w", entry.PID, err)
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to signal PID %d: %w", entry.PID, err)
}
// Wait for process to exit, escalate to SIGKILL after 30s.
// Poll the process directly via Signal(0) rather than relying on
// the daemon to self-unregister, which avoids PID reuse issues.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if err := proc.Signal(syscall.Signal(0)); err != nil {
// Process is gone.
_ = reg.Unregister(code, daemon)
cli.LogInfo(fmt.Sprintf("Stopped %s/%s (PID %d)", code, daemon, entry.PID))
return nil
}
time.Sleep(250 * time.Millisecond)
}
cli.LogWarn(fmt.Sprintf("%s/%s did not stop within 30s, sending SIGKILL", code, daemon))
_ = proc.Signal(syscall.SIGKILL)
_ = reg.Unregister(code, daemon)
cli.LogInfo(fmt.Sprintf("Killed %s/%s (PID %d)", code, daemon, entry.PID))
return nil
}
func runList() error {
reg := process.DefaultRegistry()
entries, err := reg.List()
if err != nil {
return err
}
if len(entries) == 0 {
fmt.Println("No running daemons")
return nil
}
fmt.Printf("%-20s %-12s %-8s %-24s %s\n", "CODE", "DAEMON", "PID", "HEALTH", "PROJECT")
for _, e := range entries {
project := e.Project
if project == "" {
project = "-"
}
fmt.Printf("%-20s %-12s %-8d %-24s %s\n", e.Code, e.Daemon, e.PID, e.Health, project)
}
return nil
}
// findManifest walks from cwd up to / looking for .core/manifest.yaml.
func findManifest() (*manifest.Manifest, string, error) {
dir, err := os.Getwd()
if err != nil {
return nil, "", err
}
for {
path := filepath.Join(dir, ".core", "manifest.yaml")
data, err := os.ReadFile(path)
if err == nil {
m, err := manifest.Parse(data)
if err != nil {
return nil, "", fmt.Errorf("invalid manifest at %s: %w", path, err)
}
return m, dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return nil, "", fmt.Errorf("no .core/manifest.yaml found (checked cwd and parent directories)")
}
// resolveDaemon finds the daemon entry by name or returns the default.
func resolveDaemon(m *manifest.Manifest, args []string) (string, manifest.DaemonSpec, error) {
if len(args) > 0 {
name := args[0]
spec, ok := m.Daemons[name]
if !ok {
return "", manifest.DaemonSpec{}, fmt.Errorf("daemon %q not found in manifest (available: %v)", name, daemonNames(m))
}
return name, spec, nil
}
name, spec, ok := m.DefaultDaemon()
if !ok {
return "", manifest.DaemonSpec{}, fmt.Errorf("no default daemon in manifest (use: core start <name>)")
}
return name, spec, nil
}
func daemonNames(m *manifest.Manifest) []string {
var names []string
for name := range m.Daemons {
names = append(names, name)
}
return names
}

View file

@ -1,235 +0,0 @@
// Package session provides commands for replaying and searching Claude Code session transcripts.
package session
import (
"fmt"
"os"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-session"
)
// AddSessionCommands registers the 'session' command group.
func AddSessionCommands(root *cli.Command) {
sessionCmd := &cli.Command{
Use: "session",
Short: "Session recording and replay",
}
root.AddCommand(sessionCmd)
addListCommand(sessionCmd)
addReplayCommand(sessionCmd)
addSearchCommand(sessionCmd)
}
func projectsDir() string {
home, _ := os.UserHomeDir()
// Walk .claude/projects/ looking for dirs with .jsonl files
base := filepath.Join(home, ".claude", "projects")
entries, err := os.ReadDir(base)
if err != nil {
return base
}
// Return the first project dir that has .jsonl files
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(base, e.Name())
matches, _ := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if len(matches) > 0 {
return dir
}
}
return base
}
func addListCommand(parent *cli.Command) {
listCmd := &cli.Command{
Use: "list",
Short: "List recent sessions",
RunE: func(cmd *cli.Command, args []string) error {
sessions, err := session.ListSessions(projectsDir())
if err != nil {
return err
}
if len(sessions) == 0 {
cli.Print("No sessions found")
return nil
}
cli.Print("%s", cli.HeaderStyle.Render("Recent Sessions"))
cli.Print("%s", "")
for i, s := range sessions {
if i >= 20 {
cli.Print(" ... and %d more", len(sessions)-20)
break
}
dur := s.EndTime.Sub(s.StartTime)
durStr := ""
if dur > 0 {
durStr = fmt.Sprintf(" (%s)", formatDur(dur))
}
id := s.ID
if len(id) > 8 {
id = id[:8]
}
cli.Print(" %s %s%s",
cli.ValueStyle.Render(id),
s.StartTime.Format("2006-01-02 15:04"),
cli.DimStyle.Render(durStr))
}
return nil
},
}
parent.AddCommand(listCmd)
}
func addReplayCommand(parent *cli.Command) {
var mp4 bool
var output string
replayCmd := &cli.Command{
Use: "replay <session-id>",
Short: "Generate HTML timeline (and optional MP4) from a session",
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
id := args[0]
path := findSession(id)
if path == "" {
return fmt.Errorf("session not found: %s", id)
}
cli.Print("Parsing %s...", cli.ValueStyle.Render(filepath.Base(path)))
sess, _, err := session.ParseTranscript(path)
if err != nil {
return fmt.Errorf("parse: %w", err)
}
toolCount := 0
for _, e := range sess.Events {
if e.Type == "tool_use" {
toolCount++
}
}
cli.Print(" %d events, %d tool calls",
len(sess.Events), toolCount)
// HTML output
htmlPath := output
if htmlPath == "" {
htmlPath = fmt.Sprintf("session-%s.html", shortID(sess.ID))
}
if err := session.RenderHTML(sess, htmlPath); err != nil {
return fmt.Errorf("render html: %w", err)
}
cli.Print("%s", cli.SuccessStyle.Render(fmt.Sprintf(" HTML: %s", htmlPath)))
// MP4 output
if mp4 {
mp4Path := strings.TrimSuffix(htmlPath, ".html") + ".mp4"
if err := session.RenderMP4(sess, mp4Path); err != nil {
cli.Print("%s", cli.ErrorStyle.Render(fmt.Sprintf(" MP4: %s", err)))
} else {
cli.Print("%s", cli.SuccessStyle.Render(fmt.Sprintf(" MP4: %s", mp4Path)))
}
}
return nil
},
}
replayCmd.Flags().BoolVar(&mp4, "mp4", false, "Also generate MP4 video (requires vhs + ffmpeg)")
replayCmd.Flags().StringVarP(&output, "output", "o", "", "Output file path")
parent.AddCommand(replayCmd)
}
func addSearchCommand(parent *cli.Command) {
searchCmd := &cli.Command{
Use: "search <query>",
Short: "Search across session transcripts",
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
query := strings.ToLower(strings.Join(args, " "))
results, err := session.Search(projectsDir(), query)
if err != nil {
return err
}
if len(results) == 0 {
cli.Print("No matches found")
return nil
}
cli.Print("%s", cli.HeaderStyle.Render(fmt.Sprintf("Found %d matches", len(results))))
cli.Print("%s", "")
for _, r := range results {
id := r.SessionID
if len(id) > 8 {
id = id[:8]
}
cli.Print(" %s %s %s",
cli.ValueStyle.Render(id),
r.Timestamp.Format("15:04:05"),
cli.DimStyle.Render(r.Tool))
cli.Print(" %s", truncateStr(r.Match, 100))
cli.Print("%s", "")
}
return nil
},
}
parent.AddCommand(searchCmd)
}
func findSession(id string) string {
dir := projectsDir()
// Try exact match first
path := filepath.Join(dir, id+".jsonl")
if _, err := os.Stat(path); err == nil {
return path
}
// Try prefix match
matches, _ := filepath.Glob(filepath.Join(dir, id+"*.jsonl"))
if len(matches) == 1 {
return matches[0]
}
return ""
}
func shortID(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
func formatDur(d interface {
Hours() float64
Minutes() float64
Seconds() float64
}) string {
type dur interface {
Hours() float64
Minutes() float64
Seconds() float64
}
dd := d.(dur)
h := int(dd.Hours())
m := int(dd.Minutes()) % 60
if h > 0 {
return fmt.Sprintf("%dh%dm", h, m)
}
s := int(dd.Seconds()) % 60
if m > 0 {
return fmt.Sprintf("%dm%ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}

View file

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

View file

@ -60,10 +60,10 @@ core pkg search --refresh
## pkg install ## 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 ```bash
core pkg install <org/repo> [flags] core pkg install [org/]repo [flags]
``` ```
### Flags ### Flags
@ -76,6 +76,9 @@ core pkg install <org/repo> [flags]
### Examples ### Examples
```bash ```bash
# Clone from the default host-uk org
core pkg install core-api
# Clone to packages/ # Clone to packages/
core pkg install host-uk/core-php core pkg install host-uk/core-php
@ -98,6 +101,16 @@ core pkg list
Shows installed status (✓) and description for each package. 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 ## pkg update
@ -113,6 +126,7 @@ core pkg update [<name>...] [flags]
| Flag | Description | | Flag | Description |
|------|-------------| |------|-------------|
| `--all` | Update all packages | | `--all` | Update all packages |
| `--format` | Output format (`table` or `json`) |
### Examples ### Examples
@ -122,8 +136,15 @@ core pkg update core-php
# Update all packages # Update all packages
core pkg update --all 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 ## pkg outdated
@ -136,6 +157,16 @@ core pkg outdated
Fetches from remote and shows packages that are behind. 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 ## See Also

View file

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

View file

@ -85,6 +85,11 @@ Persistent flags are inherited by all subcommands:
```go ```go
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path") cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode") 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 ## Args Validation

View file

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

View file

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

View file

@ -52,6 +52,7 @@ The framework has three layers:
| `TreeNode` | Tree structure with box-drawing connectors | | `TreeNode` | Tree structure with box-drawing connectors |
| `TaskTracker` | Concurrent task display with live spinners | | `TaskTracker` | Concurrent task display with live spinners |
| `CheckBuilder` | Fluent API for pass/fail/skip result lines | | `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) | | `AnsiStyle` | Terminal text styling (bold, dim, colour) |
## Built-in Services ## Built-in Services

View file

@ -280,4 +280,5 @@ cli.LogInfo("server started", "port", 8080)
cli.LogWarn("slow query", "duration", "3.2s") cli.LogWarn("slow query", "duration", "3.2s")
cli.LogError("connection failed", "err", err) cli.LogError("connection failed", "err", err)
cli.LogSecurity("login attempt", "user", "admin") 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: With a default selection:
```go ```go

View file

@ -34,17 +34,19 @@ When word-wrap is enabled, the stream tracks the current column position and ins
## Custom Output Writer ## 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 ```go
var buf strings.Builder var buf strings.Builder
stream := cli.NewStream(cli.WithStreamOutput(&buf)) stream := cli.NewStream(cli.WithStreamOutput(&buf))
// ... write tokens ... // ... write tokens ...
stream.Done() 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`. `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` ## Reading from `io.Reader`
@ -68,14 +70,15 @@ stream.Done()
| `Done()` | Signal completion (adds trailing newline if needed) | | `Done()` | Signal completion (adds trailing newline if needed) |
| `Wait()` | Block until `Done` is called | | `Wait()` | Block until `Done` is called |
| `Column()` | Current column position | | `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 ## Options
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `WithWordWrap(cols)` | Set the word-wrap column width | | `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 ## Example: LLM Token Streaming

171
go.mod
View file

@ -1,198 +1,45 @@
module forge.lthn.ai/core/cli module dappco.re/go/core/cli
go 1.26.0 go 1.26.0
require ( require dappco.re/go/core v0.4.7
forge.lthn.ai/core/config v0.1.0
forge.lthn.ai/core/go v0.3.0
forge.lthn.ai/core/go-cache v0.1.0
forge.lthn.ai/core/go-crypt v0.1.0
)
require ( require (
forge.lthn.ai/core/agent v0.2.0 dappco.re/go/core/i18n v0.1.7
forge.lthn.ai/core/api v0.1.0 dappco.re/go/core/log v0.0.4
forge.lthn.ai/core/go-ansible v0.1.1
forge.lthn.ai/core/go-build v0.2.0
forge.lthn.ai/core/go-container v0.1.1
forge.lthn.ai/core/go-devops v0.0.3
forge.lthn.ai/core/go-help v0.1.2
forge.lthn.ai/core/go-i18n v0.1.0
forge.lthn.ai/core/go-infra v0.1.1
forge.lthn.ai/core/go-io v0.1.0
forge.lthn.ai/core/go-log v0.0.1
forge.lthn.ai/core/go-process v0.1.2
forge.lthn.ai/core/go-scm v0.2.0
forge.lthn.ai/core/go-session v0.1.4
forge.lthn.ai/core/lint v0.3.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 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/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/term v0.41.0 golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
cloud.google.com/go v0.123.0 // indirect dappco.re/go/core v0.3.3 // indirect
code.gitea.io/sdk/gitea v0.23.2 // indirect dappco.re/go/core/inference v0.1.7 // indirect
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
forge.lthn.ai/core/go-agentic v0.0.2 // indirect
forge.lthn.ai/core/go-inference v0.1.0 // indirect
forge.lthn.ai/core/go-ratelimit v0.1.0 // indirect
forge.lthn.ai/core/go-store v0.1.3 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/Snider/Borg v0.2.0 // indirect
github.com/TwiN/go-color v1.4.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // 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/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/gin-contrib/authz v1.0.6 // indirect
github.com/gin-contrib/cors v1.7.6 // indirect
github.com/gin-contrib/expvar v1.0.3 // indirect
github.com/gin-contrib/gzip v1.2.5 // indirect
github.com/gin-contrib/httpsign v1.0.3 // indirect
github.com/gin-contrib/location/v2 v2.0.0 // indirect
github.com/gin-contrib/pprof v1.5.3 // indirect
github.com/gin-contrib/secure v1.1.2 // indirect
github.com/gin-contrib/sessions v1.0.4 // indirect
github.com/gin-contrib/slog v1.2.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-contrib/static v1.1.5 // indirect
github.com/gin-contrib/timeout v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 // indirect
github.com/leaanthony/debme v1.2.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oasdiff/oasdiff v1.11.10 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.1 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
github.com/wI2L/jsondiff v0.7.0 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
) )

426
go.sum
View file

@ -1,101 +1,19 @@
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/agent v0.2.0 h1:sx5NEeDd9uAi6lJJKj/MHIYfK+aIgcBm4hx8pJ/GvKs= forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
forge.lthn.ai/core/agent v0.2.0/go.mod h1:8LwRpgyAW70zTmPGVa6Ncs6+Y/ddFd6WLmGhJry41wU= forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/api v0.1.0 h1:ZKnQx+L9vxLQSEjwpsD1eNcIQrE4YKV1c2AlMtseM6o= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/api v0.1.0/go.mod h1:c86Lk9AmaS0xbiRCEG/+du8s9KyYNHnp8RED35gR/Fo= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/config v0.1.0 h1:qj14x/dnOWcsXMBQWAT3FtA+/sy6Qd+1NFTg5Xoil1I=
forge.lthn.ai/core/config v0.1.0/go.mod h1:8HYA29drAWlX+bO4VI1JhmKUgGU66E2Xge8D3tKd3Dg=
forge.lthn.ai/core/go v0.3.0 h1:mOG97ApMprwx9Ked62FdWVwXTGSF6JO6m0DrVpoH2Q4=
forge.lthn.ai/core/go v0.3.0/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
forge.lthn.ai/core/go-agentic v0.0.2 h1:G2nhiFY0j66A8/dyPXrS3CDYT1VLIin//GDszz4zEEo=
forge.lthn.ai/core/go-agentic v0.0.2/go.mod h1:wTZRajs+rt0YJbRk26ijC1sfICbg8O2782ZhCz2tv/k=
forge.lthn.ai/core/go-ansible v0.1.1 h1:OexkGQ5uxu1ZY6oFsBdhE6uYfdJH4vClmSsqrLCtJUo=
forge.lthn.ai/core/go-ansible v0.1.1/go.mod h1:YzzsLN6oMvA3WsiXBuvVVSs7CrNc4ncPHaGw/hst9sc=
forge.lthn.ai/core/go-build v0.2.0 h1:wFn343k/VWUneUGlVqq12Zh+FHQFPxoo90SePCK0RsM=
forge.lthn.ai/core/go-build v0.2.0/go.mod h1:7+Olm65EhM4OWwDFPpqG2WW9y9D5gl52WhOJA7sRdTY=
forge.lthn.ai/core/go-cache v0.1.0 h1:yxPf4bWPZ1jxMnXg8UHBv2xLhet2CRsq5E9PLQYjyj4=
forge.lthn.ai/core/go-cache v0.1.0/go.mod h1:7WbprZVfx/+t4cbJFXMo4sloWk2Eny+rZd8x1Ay9rLk=
forge.lthn.ai/core/go-container v0.1.1 h1:dpx0BLwGZEz1k5e7Jjmq1PgyP0Q8VgC1C/IvCN+6y5Y=
forge.lthn.ai/core/go-container v0.1.1/go.mod h1:fw/UHnrSW4cEsnRZLZkkJd+b57d1o2Lk/lOl9LwXIXQ=
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
forge.lthn.ai/core/go-devops v0.0.3 h1:tiSZ2x6a/H1A1IYYUmaM+bEuZqT9Hot7KGCEFN6PSYY=
forge.lthn.ai/core/go-devops v0.0.3/go.mod h1:V5/YaRsrDsYlSnCCJXKX7h1zSbaGyRdRQApPF5XwGAo=
forge.lthn.ai/core/go-help v0.1.2 h1:JP8hhJDAvfjvPuCyLRbU/VEm7YkENAs8debItLkon3w=
forge.lthn.ai/core/go-help v0.1.2/go.mod h1:JSZVb4Gd+P/dTc9laDJsqVCI6OrVbBbBPyPmvw3j4p4=
forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI=
forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
forge.lthn.ai/core/go-inference v0.1.0 h1:pO7etYgqV8LMKFdpW8/2RWncuECZJCIcf8nnezeZ5R4=
forge.lthn.ai/core/go-inference v0.1.0/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-infra v0.1.1 h1:1vagpgFHuvtqWtUXM3vej164Y6lDboo1HigvhpMgt7A=
forge.lthn.ai/core/go-infra v0.1.1/go.mod h1:TQdwQuMf7NJHoXU+XV5JiNF9K5VKKxVpkZvJMk+iJ4c=
forge.lthn.ai/core/go-io v0.1.0 h1:aYNvmbU2VVsjXnut0WQ4DfVxcFdheziahJB32mfeJ7g=
forge.lthn.ai/core/go-io v0.1.0/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0=
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-process v0.1.2 h1:0fdLJq/DPssilN9E5yude/xHNfZRKHghIjo++b5aXgc=
forge.lthn.ai/core/go-process v0.1.2/go.mod h1:9oxVALrZaZCqFe8YDdheIS5bRUV1SBz4tVW/MflAtxM=
forge.lthn.ai/core/go-ratelimit v0.1.0 h1:8Y6Mb/K5FMDng4B0wIh7beD05KXddi1BDwatI96XouA=
forge.lthn.ai/core/go-ratelimit v0.1.0/go.mod h1:YdpKYTjx0Amw5Wn2fenl50zVLkdfZcp7pIb3wmv0fHw=
forge.lthn.ai/core/go-scm v0.2.0 h1:TvDyCzw0HWzXjmqe6uPc46nPaRzc7MPGswmwZt0CmXo=
forge.lthn.ai/core/go-scm v0.2.0/go.mod h1:Q/PV2FbqDlWnAOsXAd1pgSiHOlRCPW4HcPmOt8Z9H+E=
forge.lthn.ai/core/go-session v0.1.4 h1:AWdE7g2Aze2XE/yMfJVE/I907Secd5Mp1CODgAm4xWY=
forge.lthn.ai/core/go-session v0.1.4/go.mod h1:c0mzZE6U05+T9s0MaNsJZ2kgW1cqIRH/KIGbaBXG16k=
forge.lthn.ai/core/go-store v0.1.3 h1:CSVTRdsOXm2pl+FCs12fHOc9eM88DcZRY6HghN98w/I=
forge.lthn.ai/core/go-store v0.1.3/go.mod h1:op+ftjAqYskPv4OGvHZQf7/DLiRnFIdT0XCQTKR/GjE=
forge.lthn.ai/core/lint v0.3.0 h1:ar5TSsoMsHWbfubQbWQAEqBzHixy93un1rWZQTjB/B0=
forge.lthn.ai/core/lint v0.3.0/go.mod h1:0/1HXRIX2qV2+dQH0HmUMMV9u1hEta6tj3K+mpo4Kdg=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
@ -108,372 +26,60 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k=
github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw=
github.com/gin-contrib/expvar v1.0.3/go.mod h1:bwqqmhty1Zl2JYVLzBIL6CSHDWDbQoQoicalAnBvUnY=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I=
github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k=
github.com/gin-contrib/location/v2 v2.0.0 h1:iLx5RatHQHSxgC0tm2AG0sIuQKecI7FhREessVd6RWY=
github.com/gin-contrib/location/v2 v2.0.0/go.mod h1:276TDNr25NENBA/NQZUuEIlwxy/I5CYVFIr/d2TgOdU=
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1/go.mod h1:3ebNU9QBrNpUO+Hj6bHaGpkh5pymDHQ+wwVPHTE4mCE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oasdiff/oasdiff v1.11.10 h1:4I9VrktUoHmwydkJqVOC7Bd6BXKu9dc4UUP3PIu1VjM=
github.com/oasdiff/oasdiff v1.11.10/go.mod h1:GXARzmqBKN8lZHsTQD35ZM41ePbu6JdAZza4sRMeEKg=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/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=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

51
main.go
View file

@ -1,51 +0,0 @@
package main
import (
"forge.lthn.ai/core/cli/cmd/config"
"forge.lthn.ai/core/cli/cmd/doctor"
"forge.lthn.ai/core/cli/cmd/gocmd"
"forge.lthn.ai/core/cli/cmd/help"
"forge.lthn.ai/core/cli/cmd/module"
"forge.lthn.ai/core/cli/cmd/pkgcmd"
"forge.lthn.ai/core/cli/cmd/plugin"
"forge.lthn.ai/core/cli/cmd/service"
"forge.lthn.ai/core/cli/cmd/session"
"forge.lthn.ai/core/cli/pkg/cli"
// Ecosystem command packages — self-register via init() + cli.RegisterCommands()
_ "forge.lthn.ai/core/agent/cmd/agent"
_ "forge.lthn.ai/core/agent/cmd/dispatch"
_ "forge.lthn.ai/core/agent/cmd/taskgit"
_ "forge.lthn.ai/core/go-ansible/cmd/ansible"
_ "forge.lthn.ai/core/api/cmd/api"
_ "forge.lthn.ai/core/go-build/cmd/build"
_ "forge.lthn.ai/core/go-build/cmd/ci"
_ "forge.lthn.ai/core/go-build/cmd/sdk"
_ "forge.lthn.ai/core/go-container/cmd/vm"
_ "forge.lthn.ai/core/go-crypt/cmd/crypt"
_ "forge.lthn.ai/core/go-devops/cmd/deploy"
_ "forge.lthn.ai/core/go-devops/cmd/dev"
_ "forge.lthn.ai/core/go-devops/cmd/docs"
_ "forge.lthn.ai/core/go-devops/cmd/gitcmd"
_ "forge.lthn.ai/core/go-devops/cmd/setup"
_ "forge.lthn.ai/core/go-infra/cmd/monitor"
_ "forge.lthn.ai/core/go-infra/cmd/prod"
_ "forge.lthn.ai/core/go-scm/cmd/collect"
_ "forge.lthn.ai/core/go-scm/cmd/forge"
_ "forge.lthn.ai/core/go-scm/cmd/gitea"
_ "forge.lthn.ai/core/lint/cmd/qa"
)
func main() {
cli.Main(
cli.WithCommands("config", config.AddConfigCommands),
cli.WithCommands("doctor", doctor.AddDoctorCommands),
cli.WithCommands("help", help.AddHelpCommands),
cli.WithCommands("module", module.AddModuleCommands),
cli.WithCommands("pkg", pkgcmd.AddPkgCommands),
cli.WithCommands("plugin", plugin.AddPluginCommands),
cli.WithCommands("session", session.AddSessionCommands),
cli.WithCommands("go", gocmd.AddGoCommands),
cli.WithCommands("service", service.AddServiceCommands),
)
}

View file

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

View file

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

View file

@ -1,17 +1,21 @@
package cli package cli
import ( import (
"embed"
"fmt" "fmt"
"io/fs"
"os" "os"
"runtime/debug" "runtime/debug"
"forge.lthn.ai/core/go-crypt/crypt/openpgp" "dappco.re/go/core"
"forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-io/workspace"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
//go:embed locales/*.json
var cliLocaleFS embed.FS
// AppName is the default CLI application name. // AppName is the default CLI application name.
// Override with WithAppName before calling Main. // Override with WithAppName before calling Main.
var AppName = "core" var AppName = "core"
@ -30,9 +34,16 @@ var (
) )
// SemVer returns the full SemVer 2.0.0 version string. // SemVer returns the full SemVer 2.0.0 version string.
// - Release: 1.2.0 //
// - Pre-release: 1.2.0-dev.8 // Examples:
// - Full: 1.2.0-dev.8+df94c24.20260206 // // 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 { func SemVer() string {
v := AppVersion v := AppVersion
if BuildPreRelease != "" { if BuildPreRelease != "" {
@ -56,17 +67,42 @@ func WithAppName(name string) {
AppName = name AppName = name
} }
// Main initialises and runs the CLI application. // LocaleSource pairs a filesystem with a directory for loading translations.
// Pass command services via WithCommands to register CLI commands type LocaleSource = i18n.FSSource
// through the Core framework lifecycle.
// 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.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands), // cli.WithCommands("doctor", doctor.AddDoctorCommands),
// ) // )
type CommandSetup func(c *core.Core)
// Main initialises and runs the CLI with the framework's built-in translations.
// //
// Exits with code 1 on error or panic. // Example:
func Main(commands ...core.Option) { // 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 // Recovery from panics
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -76,28 +112,31 @@ func Main(commands ...core.Option) {
} }
}() }()
// Core services load first, then command services // Build locale sources: framework built-in + caller's extras + registered packages
services := []core.Option{ extraFS := []i18n.FSSource{
core.WithName("i18n", NewI18nService(I18nOptions{})), {FS: cliLocaleFS, Dir: "locales"},
core.WithName("log", NewLogService(log.Options{ }
Level: log.LevelInfo, extraFS = append(extraFS, locales...)
})), for _, lfs := range RegisteredLocales() {
core.WithName("crypt", openpgp.New), extraFS = append(extraFS, i18n.FSSource{FS: lfs, Dir: "."})
core.WithName("workspace", workspace.New),
} }
services = append(services, commands...)
// Initialise CLI runtime with services // Initialise CLI runtime
if err := Init(Options{ if err := Init(Options{
AppName: AppName, AppName: AppName,
Version: SemVer(), Version: SemVer(),
Services: services, I18nSources: extraFS,
}); err != nil { }); err != nil {
Error(err.Error()) Error(err.Error())
os.Exit(1) os.Exit(1)
} }
defer Shutdown() defer Shutdown()
// Run command setup functions
for _, setup := range commands {
setup(Core())
}
// Add completion command to the CLI's root // Add completion command to the CLI's root
RootCmd().AddCommand(newCompletionCmd()) RootCmd().AddCommand(newCompletionCmd())
@ -161,13 +200,13 @@ PowerShell:
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
switch args[0] { switch args[0] {
case "bash": case "bash":
_ = cmd.Root().GenBashCompletion(os.Stdout) _ = cmd.Root().GenBashCompletion(stdoutWriter())
case "zsh": case "zsh":
_ = cmd.Root().GenZshCompletion(os.Stdout) _ = cmd.Root().GenZshCompletion(stdoutWriter())
case "fish": case "fish":
_ = cmd.Root().GenFishCompletion(os.Stdout, true) _ = cmd.Root().GenFishCompletion(stdoutWriter(), true)
case "powershell": case "powershell":
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) _ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter())
} }
}, },
} }

View file

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

View file

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

View file

@ -173,6 +173,32 @@ func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []stri
} }
} }
// StringArrayFlag adds a string array flag to a command.
// The value will be stored in the provided pointer.
//
// var tags []string
// cli.StringArrayFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
func StringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.Flags().StringArrayVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringArrayVar(ptr, name, def, usage)
}
}
// StringToStringFlag adds a string-to-string map flag to a command.
// The value will be stored in the provided pointer.
//
// var labels map[string]string
// cli.StringToStringFlag(cmd, &labels, "label", "l", nil, "Labels to apply")
func StringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
if short != "" {
cmd.Flags().StringToStringVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringToStringVar(ptr, name, def, usage)
}
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Persistent Flag Helpers // 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 // Command Configuration
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

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

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

View file

@ -2,76 +2,150 @@
package cli package cli
import ( import (
"context" "io/fs"
"iter" "iter"
"sync" "sync"
"forge.lthn.ai/core/go/pkg/core" "dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// WithCommands creates a framework Option that registers a command group. // WithCommands returns a CommandSetup that registers a command group.
// The register function receives the root command during service startup, // The register function receives the root cobra command during Main().
// allowing commands to participate in the Core lifecycle.
// //
// cli.Main( // cli.Main(
// cli.WithCommands("config", config.AddConfigCommands), // cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands), // cli.WithCommands("doctor", doctor.AddDoctorCommands),
// ) // )
func WithCommands(name string, register func(root *Command)) core.Option { func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup {
return core.WithName("cmd."+name, func(c *core.Core) (any, error) { return func(c *core.Core) {
return &commandService{core: c, register: register}, nil loadLocaleSources(localeSourcesFromFS(localeFS...)...)
}) if root, ok := c.App().Runtime.(*cobra.Command); ok {
} register(root)
}
type commandService struct { appendLocales(localeFS...)
core *core.Core
register func(root *Command)
}
func (s *commandService) OnStartup(_ context.Context) error {
if root, ok := s.core.App.(*cobra.Command); ok {
s.register(root)
} }
return nil
} }
// CommandRegistration is a function that adds commands to the root. // 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) type CommandRegistration func(root *cobra.Command)
var ( var (
registeredCommands []CommandRegistration registeredCommands []CommandRegistration
registeredCommandsMu sync.Mutex registeredCommandsMu sync.Mutex
commandsAttached bool commandsAttached bool
registeredLocales []fs.FS
) )
// RegisterCommands registers a function that adds commands to the CLI. // RegisterCommands registers a function that adds commands to the CLI.
// Call this in your package's init() to register commands. // Optionally pass a locale fs.FS to provide translations for the commands.
// //
// func init() { // func init() {
// cli.RegisterCommands(AddCommands) // cli.RegisterCommands(AddCommands, locales.FS)
// } // }
// //
// func AddCommands(root *cobra.Command) { // Example:
// root.AddCommand(myCmd) // cli.RegisterCommands(func(root *cobra.Command) {
// } // root.AddCommand(cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
func RegisterCommands(fn CommandRegistration) { // cli.Println(cli.SemVer())
// }))
// })
func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
registeredCommandsMu.Lock() registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
registeredCommands = append(registeredCommands, fn) registeredCommands = append(registeredCommands, fn)
attached := commandsAttached && instance != nil && instance.root != nil
root := instance
registeredCommandsMu.Unlock()
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
appendLocales(localeFS...)
// If commands already attached (CLI already running), attach immediately // If commands already attached (CLI already running), attach immediately
if commandsAttached && instance != nil && instance.root != nil { if attached {
fn(instance.root) fn(root.root)
} }
} }
// appendLocales appends non-nil locale filesystems to the registry.
func appendLocales(localeFS ...fs.FS) {
var nonempty []fs.FS
for _, lfs := range localeFS {
if lfs != nil {
nonempty = append(nonempty, lfs)
}
}
if len(nonempty) == 0 {
return
}
registeredCommandsMu.Lock()
registeredLocales = append(registeredLocales, nonempty...)
registeredCommandsMu.Unlock()
}
func localeSourcesFromFS(localeFS ...fs.FS) []LocaleSource {
sources := make([]LocaleSource, 0, len(localeFS))
for _, lfs := range localeFS {
if lfs != nil {
sources = append(sources, LocaleSource{FS: lfs, Dir: "."})
}
}
return sources
}
func loadLocaleSources(sources ...LocaleSource) {
svc := i18n.Default()
if svc == nil {
return
}
for _, src := range sources {
if src.FS == nil {
continue
}
if err := svc.AddLoader(i18n.NewFSLoader(src.FS, src.Dir)); err != nil {
LogDebug("failed to load locale source", "dir", src.Dir, "err", err)
}
}
}
// RegisteredLocales returns all locale filesystems registered by command packages.
//
// Example:
// for _, fs := range cli.RegisteredLocales() {
// _ = fs
// }
func RegisteredLocales() []fs.FS {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
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. // RegisteredCommands returns an iterator over the registered command functions.
//
// Example:
// for attach := range cli.RegisteredCommands() {
// _ = attach
// }
func RegisteredCommands() iter.Seq[CommandRegistration] { func RegisteredCommands() iter.Seq[CommandRegistration] {
return func(yield func(CommandRegistration) bool) { return func(yield func(CommandRegistration) bool) {
registeredCommandsMu.Lock() registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock() snapshot := make([]CommandRegistration, len(registeredCommands))
for _, fn := range registeredCommands { copy(snapshot, registeredCommands)
registeredCommandsMu.Unlock()
for _, fn := range snapshot {
if !yield(fn) { if !yield(fn) {
return return
} }
@ -83,11 +157,12 @@ func RegisteredCommands() iter.Seq[CommandRegistration] {
// Called by Init() after creating the root command. // Called by Init() after creating the root command.
func attachRegisteredCommands(root *cobra.Command) { func attachRegisteredCommands(root *cobra.Command) {
registeredCommandsMu.Lock() 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) fn(root)
} }
commandsAttached = true
} }

View file

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

View file

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

View file

@ -1,264 +0,0 @@
package cli
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"syscall"
"time"
"forge.lthn.ai/core/go-process"
)
// DaemonCommandConfig configures the generic daemon CLI command group.
type DaemonCommandConfig struct {
// Name is the command group name (default: "daemon").
Name string
// Description is the short description for the command group.
Description string
// RunForeground is called when the daemon runs in foreground mode.
// Receives context (cancelled on SIGINT/SIGTERM) and the started Daemon.
// If nil, the run command just blocks until signal.
RunForeground func(ctx context.Context, daemon *process.Daemon) error
// PIDFile default path.
PIDFile string
// HealthAddr default address.
HealthAddr string
// ExtraStartArgs returns additional CLI args to pass when re-execing
// the binary as a background daemon.
ExtraStartArgs func() []string
// Flags registers custom persistent flags on the daemon command group.
Flags func(cmd *Command)
}
// AddDaemonCommand registers start/stop/status/run subcommands on root.
func AddDaemonCommand(root *Command, cfg DaemonCommandConfig) {
if cfg.Name == "" {
cfg.Name = "daemon"
}
if cfg.Description == "" {
cfg.Description = "Manage the background daemon"
}
daemonCmd := NewGroup(
cfg.Name,
cfg.Description,
fmt.Sprintf("Manage the background daemon process.\n\n"+
"Subcommands:\n"+
" start - Start the daemon in the background\n"+
" stop - Stop the running daemon\n"+
" status - Show daemon status\n"+
" run - Run in foreground (for development/debugging)"),
)
PersistentStringFlag(daemonCmd, &cfg.HealthAddr, "health-addr", "", cfg.HealthAddr,
"Health check endpoint address (empty to disable)")
PersistentStringFlag(daemonCmd, &cfg.PIDFile, "pid-file", "", cfg.PIDFile,
"PID file path (empty to disable)")
if cfg.Flags != nil {
cfg.Flags(daemonCmd)
}
startCmd := NewCommand("start", "Start the daemon in the background",
"Re-executes the binary as a background daemon process.\n"+
"The daemon PID is written to the PID file for later management.",
func(cmd *Command, args []string) error {
return daemonRunStart(cfg)
},
)
stopCmd := NewCommand("stop", "Stop the running daemon",
"Sends SIGTERM to the daemon process identified by the PID file.\n"+
"Waits for graceful shutdown before returning.",
func(cmd *Command, args []string) error {
return daemonRunStop(cfg)
},
)
statusCmd := NewCommand("status", "Show daemon status",
"Checks if the daemon is running and queries its health endpoint.",
func(cmd *Command, args []string) error {
return daemonRunStatus(cfg)
},
)
runCmd := NewCommand("run", "Run the daemon in the foreground",
"Runs the daemon in the current terminal (blocks until SIGINT/SIGTERM).\n"+
"Useful for development, debugging, or running under a process manager.",
func(cmd *Command, args []string) error {
return daemonRunForeground(cfg)
},
)
daemonCmd.AddCommand(startCmd, stopCmd, statusCmd, runCmd)
root.AddCommand(daemonCmd)
}
func daemonRunStart(cfg DaemonCommandConfig) error {
if pid, running := process.ReadPID(cfg.PIDFile); running {
return fmt.Errorf("daemon already running (PID %d)", pid)
}
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to find executable: %w", err)
}
args := []string{cfg.Name, "run",
"--health-addr", cfg.HealthAddr,
"--pid-file", cfg.PIDFile,
}
if cfg.ExtraStartArgs != nil {
args = append(args, cfg.ExtraStartArgs()...)
}
cmd := exec.Command(exePath, args...)
cmd.Env = append(os.Environ(), "CORE_DAEMON=1")
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start daemon: %w", err)
}
pid := cmd.Process.Pid
_ = cmd.Process.Release()
if cfg.HealthAddr != "" {
if process.WaitForHealth(cfg.HealthAddr, 5_000) {
LogInfo(fmt.Sprintf("Daemon started (PID %d, health %s)", pid, cfg.HealthAddr))
} else {
LogInfo(fmt.Sprintf("Daemon started (PID %d, health not yet ready)", pid))
}
} else {
LogInfo(fmt.Sprintf("Daemon started (PID %d)", pid))
}
return nil
}
func daemonRunStop(cfg DaemonCommandConfig) error {
pid, running := process.ReadPID(cfg.PIDFile)
if !running {
LogInfo("Daemon is not running")
return nil
}
proc, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("failed to find process %d: %w", pid, err)
}
LogInfo(fmt.Sprintf("Stopping daemon (PID %d)", pid))
if err := proc.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to send SIGTERM to PID %d: %w", pid, err)
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if err := proc.Signal(syscall.Signal(0)); err != nil {
// Process is gone — clean up PID file if it lingers.
_ = os.Remove(cfg.PIDFile)
LogInfo("Daemon stopped")
return nil
}
time.Sleep(250 * time.Millisecond)
}
LogWarn("Daemon did not stop within 30s, sending SIGKILL")
_ = proc.Signal(syscall.SIGKILL)
_ = os.Remove(cfg.PIDFile)
LogInfo("Daemon killed")
return nil
}
func daemonRunStatus(cfg DaemonCommandConfig) error {
pid, running := process.ReadPID(cfg.PIDFile)
if !running {
fmt.Println("Daemon is not running")
return nil
}
fmt.Printf("Daemon is running (PID %d)\n", pid)
if cfg.HealthAddr != "" {
healthURL := fmt.Sprintf("http://%s/health", cfg.HealthAddr)
resp, err := http.Get(healthURL)
if err != nil {
fmt.Printf("Health: unreachable (%v)\n", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
fmt.Println("Health: ok")
} else {
fmt.Printf("Health: unhealthy (HTTP %d)\n", resp.StatusCode)
}
readyURL := fmt.Sprintf("http://%s/ready", cfg.HealthAddr)
resp2, err := http.Get(readyURL)
if err == nil {
defer resp2.Body.Close()
if resp2.StatusCode == http.StatusOK {
fmt.Println("Ready: yes")
} else {
fmt.Println("Ready: no")
}
}
}
return nil
}
func daemonRunForeground(cfg DaemonCommandConfig) error {
os.Setenv("CORE_DAEMON", "1")
daemon := process.NewDaemon(process.DaemonOptions{
PIDFile: cfg.PIDFile,
HealthAddr: cfg.HealthAddr,
ShutdownTimeout: 30 * time.Second,
})
if err := daemon.Start(); err != nil {
return fmt.Errorf("failed to start daemon: %w", err)
}
daemon.SetReady(true)
ctx := Context()
if cfg.RunForeground != nil {
svcErr := make(chan error, 1)
go func() {
svcErr <- cfg.RunForeground(ctx, daemon)
}()
select {
case <-ctx.Done():
LogInfo("Shutting down daemon")
case err := <-svcErr:
if err != nil {
LogError(fmt.Sprintf("Service exited with error: %v", err))
}
}
} else {
<-ctx.Done()
}
return daemon.Stop()
}

View file

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

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

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

View file

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

View file

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

View file

@ -78,6 +78,12 @@ func Join(errs ...error) error {
} }
// ExitError represents an error that should cause the CLI to exit with a specific code. // 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 { type ExitError struct {
Code int Code int
Err error Err error
@ -95,7 +101,8 @@ func (e *ExitError) Unwrap() error {
} }
// Exit creates a new ExitError with the given code and 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 { func Exit(code int, err error) error {
if err == nil { if err == nil {
return nil return nil
@ -113,7 +120,7 @@ func Exit(code int, err error) error {
func Fatal(err error) { func Fatal(err error) {
if err != nil { if err != nil {
LogError("Fatal error", "err", err) 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) os.Exit(1)
} }
} }
@ -124,7 +131,7 @@ func Fatal(err error) {
func Fatalf(format string, args ...any) { func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
LogError("Fatal error", "msg", msg) 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) os.Exit(1)
} }
@ -140,7 +147,7 @@ func FatalWrap(err error, msg string) {
} }
LogError("Fatal error", "msg", msg, "err", err) LogError("Fatal error", "msg", msg, "err", err)
fullMsg := fmt.Sprintf("%s: %v", msg, 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) os.Exit(1)
} }
@ -157,6 +164,6 @@ func FatalWrapVerb(err error, verb, subject string) {
msg := i18n.ActionFailed(verb, subject) msg := i18n.ActionFailed(verb, subject)
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject) LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
fullMsg := fmt.Sprintf("%s: %v", msg, 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) os.Exit(1)
} }

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

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

View file

@ -10,6 +10,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term" "golang.org/x/term"
) )
@ -60,7 +61,7 @@ func NewFrame(variant string) *Frame {
variant: variant, variant: variant,
layout: Layout(variant), layout: Layout(variant),
models: make(map[Region]Model), models: make(map[Region]Model),
out: os.Stdout, out: stderrWriter(),
done: make(chan struct{}), done: make(chan struct{}),
focused: RegionContent, focused: RegionContent,
keyMap: DefaultKeyMap(), 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. // Header sets the Header region model.
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f } func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
@ -428,6 +438,7 @@ func (f *Frame) String() string {
if view == "" { if view == "" {
return "" return ""
} }
view = ansi.Strip(view)
// Ensure trailing newline for non-TTY consistency // Ensure trailing newline for non-TTY consistency
if !strings.HasSuffix(view, "\n") { if !strings.HasSuffix(view, "\n") {
view += "\n" view += "\n"
@ -452,12 +463,11 @@ func (f *Frame) termSize() (int, int) {
return 80, 24 // sensible default return 80, 24 // sensible default
} }
func (f *Frame) runLive() { func (f *Frame) runLive() {
opts := []tea.ProgramOption{ opts := []tea.ProgramOption{
tea.WithAltScreen(), tea.WithAltScreen(),
} }
if f.out != os.Stdout { if f.out != stdoutWriter() {
opts = append(opts, tea.WithOutput(f.out)) 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 { 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 { for _, p := range s.pairs {
parts = append(parts, DimStyle.Render(p)) parts = append(parts, DimStyle.Render(compileGlyphs(p)))
} }
line := strings.Join(parts, " ") line := strings.Join(parts, " ")
if width > 0 { if width > 0 {
@ -46,7 +46,7 @@ func KeyHints(hints ...string) Model {
func (k *keyHintsModel) View(width, _ int) string { func (k *keyHintsModel) View(width, _ int) string {
parts := make([]string, len(k.hints)) parts := make([]string, len(k.hints))
for i, h := range k.hints { for i, h := range k.hints {
parts[i] = DimStyle.Render(h) parts[i] = DimStyle.Render(compileGlyphs(h))
} }
line := strings.Join(parts, " ") line := strings.Join(parts, " ")
if width > 0 { if width > 0 {
@ -70,10 +70,11 @@ func Breadcrumb(parts ...string) Model {
func (b *breadcrumbModel) View(width, _ int) string { func (b *breadcrumbModel) View(width, _ int) string {
styled := make([]string, len(b.parts)) styled := make([]string, len(b.parts))
for i, p := range b.parts { for i, p := range b.parts {
part := compileGlyphs(p)
if i == len(b.parts)-1 { if i == len(b.parts)-1 {
styled[i] = BoldStyle.Render(p) styled[i] = BoldStyle.Render(part)
} else { } else {
styled[i] = DimStyle.Render(p) styled[i] = DimStyle.Render(part)
} }
} }
line := strings.Join(styled, DimStyle.Render(" > ")) line := strings.Join(styled, DimStyle.Render(" > "))
@ -94,5 +95,5 @@ func StaticModel(text string) Model {
} }
func (s *staticModel) View(_, _ int) string { func (s *staticModel) View(_, _ int) string {
return s.text return compileGlyphs(s.text)
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,170 +1,17 @@
package cli package cli
import ( import (
"context"
"sync"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
) )
// I18nService wraps i18n as a Core service.
type I18nService struct {
*core.ServiceRuntime[I18nOptions]
svc *i18n.Service
// Collect mode state
missingKeys []i18n.MissingKey
missingKeysMu sync.Mutex
}
// I18nOptions configures the i18n service.
type I18nOptions struct {
// Language overrides auto-detection (e.g., "en-GB", "de")
Language string
// Mode sets the translation mode (Normal, Strict, Collect)
Mode i18n.Mode
}
// NewI18nService creates an i18n service factory.
func NewI18nService(opts I18nOptions) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
svc, err := i18n.New()
if err != nil {
return nil, err
}
if opts.Language != "" {
_ = svc.SetLanguage(opts.Language)
}
// Set mode if specified
svc.SetMode(opts.Mode)
// Set as global default so i18n.T() works everywhere
i18n.SetDefault(svc)
return &I18nService{
ServiceRuntime: core.NewServiceRuntime(c, opts),
svc: svc,
missingKeys: make([]i18n.MissingKey, 0),
}, nil
}
}
// OnStartup initialises the i18n service.
func (s *I18nService) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
// Register action handler for collect mode
if s.svc.Mode() == i18n.ModeCollect {
i18n.OnMissingKey(s.handleMissingKey)
}
return nil
}
// handleMissingKey accumulates missing keys in collect mode.
func (s *I18nService) handleMissingKey(mk i18n.MissingKey) {
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
s.missingKeys = append(s.missingKeys, mk)
}
// MissingKeys returns all missing keys collected in collect mode.
// Call this at the end of a QA session to report missing translations.
func (s *I18nService) MissingKeys() []i18n.MissingKey {
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
result := make([]i18n.MissingKey, len(s.missingKeys))
copy(result, s.missingKeys)
return result
}
// ClearMissingKeys resets the collected missing keys.
func (s *I18nService) ClearMissingKeys() {
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
s.missingKeys = s.missingKeys[:0]
}
// SetMode changes the translation mode.
func (s *I18nService) SetMode(mode i18n.Mode) {
s.svc.SetMode(mode)
// Update action handler registration
if mode == i18n.ModeCollect {
i18n.OnMissingKey(s.handleMissingKey)
} else {
i18n.OnMissingKey(nil)
}
}
// Mode returns the current translation mode.
func (s *I18nService) Mode() i18n.Mode {
return s.svc.Mode()
}
// Queries for i18n service
// QueryTranslate requests a translation.
type QueryTranslate struct {
Key string
Args map[string]any
}
func (s *I18nService) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch m := q.(type) {
case QueryTranslate:
return s.svc.T(m.Key, m.Args), true, nil
}
return nil, false, nil
}
// T translates a key with optional arguments.
func (s *I18nService) T(key string, args ...map[string]any) string {
if len(args) > 0 {
return s.svc.T(key, args[0])
}
return s.svc.T(key)
}
// SetLanguage changes the current language.
func (s *I18nService) SetLanguage(lang string) {
_ = s.svc.SetLanguage(lang)
}
// Language returns the current language.
func (s *I18nService) Language() string {
return s.svc.Language()
}
// AvailableLanguages returns all available languages.
func (s *I18nService) AvailableLanguages() []string {
return s.svc.AvailableLanguages()
}
// --- Package-level convenience ---
// T translates a key using the CLI's i18n service. // T translates a key using the CLI's i18n service.
// Falls back to the global i18n.T if CLI not initialised. // Falls back to the global i18n.T if CLI not initialised.
//
// label := cli.T("cmd.doctor.required")
// msg := cli.T("cmd.doctor.issues", map[string]any{"Count": 3})
func T(key string, args ...map[string]any) string { func T(key string, args ...map[string]any) string {
if instance == nil {
// CLI not initialised, use global i18n
if len(args) > 0 { if len(args) > 0 {
return i18n.T(key, args[0]) return i18n.T(key, args[0])
} }
return i18n.T(key) return i18n.T(key)
}
svc, err := core.ServiceFor[*I18nService](instance.core, "i18n")
if err != nil {
// i18n service not registered, use global
if len(args) > 0 {
return i18n.T(key, args[0])
}
return i18n.T(key)
}
return svc.T(key, args...)
} }

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

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

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

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

View file

@ -68,7 +68,7 @@ type Renderable interface {
type StringBlock string type StringBlock string
// Render returns the string content. // 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. // Layout creates a new layout from a variant string.
func Layout(variant string) *Composite { func Layout(variant string) *Composite {

View file

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

141
pkg/cli/locales/en.json Normal file
View file

@ -0,0 +1,141 @@
{
"cmd": {
"doctor": {
"short": "Check development environment",
"long": "Diagnose your development environment and report missing tools, configuration issues, and connectivity problems.",
"verbose_flag": "Show detailed output",
"required": "Required tools:",
"optional": "Optional tools:",
"github": "GitHub integration:",
"workspace": "Workspace:",
"ready": "Environment is ready",
"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",
"install_linux_pnpm": "npm install -g pnpm",
"install_linux_gh": "See https://github.com/cli/cli/blob/trunk/docs/install_linux.md",
"install_other": "See tool documentation for installation",
"issues": "Open issues assigned to you:",
"issues_error": "Failed to fetch issues",
"cli_auth": "GitHub CLI authenticated",
"cli_auth_missing": "GitHub CLI not authenticated — run: gh auth login",
"ssh_found": "SSH key found",
"ssh_missing": "SSH key not found — run: ssh-keygen",
"repos_yaml_found": "Workspace registry found: {{.Path}}",
"repos_cloned": "{{.Cloned}}/{{.Total}} repos cloned",
"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" },
"composer": { "name": "Composer", "description": "PHP package manager" },
"pnpm": { "name": "pnpm", "description": "Node package manager" },
"gh": { "name": "GitHub CLI", "description": "GitHub integration" },
"claude": { "name": "Claude Code", "description": "AI coding assistant" }
}
},
"pkg": {
"short": "Manage packages",
"long": "Install, list, search, update, and remove packages from the Core ecosystem.",
"no_description": "(no description)",
"error": {
"repo_required": "Repository argument required (e.g., core/go-io)",
"invalid_repo_format": "Invalid format — use org/repo (e.g., core/go-io)",
"no_repos_yaml": "No repos.yaml found",
"no_repos_yaml_workspace": "No repos.yaml found in workspace",
"specify_package": "Specify a package name",
"auth_failed": "Authentication failed",
"gh_not_authenticated": "GitHub CLI not authenticated — run: gh auth login",
"search_failed": "Search failed"
},
"install": {
"short": "Install a package",
"long": "Clone a package from the Git forge into your workspace.",
"installing_label": "Installing",
"already_exists": "{{.Name}} already exists at {{.Path}}",
"installed": "{{.Name}} installed successfully",
"add_to_registry": "Adding to registry",
"added_to_registry": "Added to repos.yaml",
"flag": {
"dir": "Target directory (default: workspace base path)",
"add": "Add to repos.yaml registry after install"
}
},
"list": {
"short": "List installed packages",
"long": "Show all packages registered in repos.yaml with their status.",
"title": "Installed packages",
"no_packages": "No packages found",
"summary": "{{.Count}} packages",
"install_missing": "Install missing: core pkg install"
},
"search": {
"short": "Search available packages",
"long": "Search the forge for available packages by name or pattern.",
"fetching_label": "Searching",
"cache_label": "Cached",
"found_repos": "Found {{.Count}} repositories",
"no_repos_found": "No matching repositories found",
"private_label": "private",
"gh_token_unset": "GITHUB_TOKEN not set",
"gh_token_warning": "Set GITHUB_TOKEN for private repo access",
"flag": {
"org": "Organisation to search (default: core)",
"pattern": "Filter by name pattern",
"type": "Filter by type (package, application, template)",
"limit": "Maximum results",
"refresh": "Refresh cache"
}
},
"update": {
"short": "Update a package",
"long": "Pull latest changes for a package or all packages.",
"updating": "Updating {{.Name}}",
"not_installed": "{{.Name}} is not installed",
"summary": "{{.Updated}}/{{.Total}} updated",
"flag": {
"all": "Update all packages"
}
},
"outdated": {
"short": "Show outdated packages",
"long": "Check which packages have unpulled commits.",
"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",
"flag": {
"format": "Output format: table or json"
}
}
}
},
"common": {
"hint": {
"install_with": "Install with: {{.Command}}"
},
"progress": {
"checking": "Checking {{.Item}}...",
"checking_updates": "Checking for updates..."
},
"status": {
"cloning": "Cloning",
"up_to_date": "Up to date"
}
},
"i18n": {
"fail": {
"create": "Failed to create {{.Item}}",
"load": "Failed to load {{.Item}}",
"parse": "Failed to parse {{.Item}}"
}
}
}

View file

@ -1,115 +1,50 @@
package cli package cli
import ( import (
"forge.lthn.ai/core/go/pkg/core" "fmt"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go-log"
) )
// LogLevel aliases for backwards compatibility. // LogLevel aliases for convenience.
type LogLevel = log.Level type LogLevel = log.Level
// Log level constants aliased from the log package.
const ( const (
// LogLevelQuiet suppresses all output.
LogLevelQuiet = log.LevelQuiet LogLevelQuiet = log.LevelQuiet
// LogLevelError shows only error messages.
LogLevelError = log.LevelError LogLevelError = log.LevelError
// LogLevelWarn shows warnings and errors.
LogLevelWarn = log.LevelWarn LogLevelWarn = log.LevelWarn
// LogLevelInfo shows info, warnings, and errors.
LogLevelInfo = log.LevelInfo LogLevelInfo = log.LevelInfo
// LogLevelDebug shows all messages including debug.
LogLevelDebug = log.LevelDebug LogLevelDebug = log.LevelDebug
) )
// LogService wraps log.Service with CLI styling. // LogDebug logs a debug message if the default logger is available.
type LogService struct { //
*log.Service // cli.LogDebug("cache miss", "key", cacheKey)
} func LogDebug(msg string, keyvals ...any) { log.Debug(msg, keyvals...) }
// LogOptions configures the log service. // LogInfo logs an info message.
type LogOptions = log.Options //
// cli.LogInfo("configuration reloaded", "path", configPath)
// NewLogService creates a log service factory with CLI styling. func LogInfo(msg string, keyvals ...any) { log.Info(msg, keyvals...) }
func NewLogService(opts LogOptions) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) { // LogWarn logs a warning message.
// Create the underlying service //
factory := log.NewService(opts) // cli.LogWarn("GitHub CLI not authenticated", "user", username)
svc, err := factory(c) func LogWarn(msg string, keyvals ...any) { log.Warn(msg, keyvals...) }
if err != nil {
return nil, err // LogError logs an error message.
} //
// cli.LogError("Fatal error", "err", err)
logSvc := svc.(*log.Service) func LogError(msg string, keyvals ...any) { log.Error(msg, keyvals...) }
// Apply CLI styles // LogSecurity logs a security-sensitive message.
logSvc.StyleTimestamp = func(s string) string { return DimStyle.Render(s) } //
logSvc.StyleDebug = func(s string) string { return DimStyle.Render(s) } // cli.LogSecurity("login attempt", "user", "admin")
logSvc.StyleInfo = func(s string) string { return InfoStyle.Render(s) } func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
logSvc.StyleWarn = func(s string) string { return WarningStyle.Render(s) }
logSvc.StyleError = func(s string) string { return ErrorStyle.Render(s) } // LogSecurityf logs a formatted security-sensitive message.
logSvc.StyleSecurity = func(s string) string { return SecurityStyle.Render(s) } //
// cli.LogSecurityf("login attempt from %s", username)
return &LogService{Service: logSvc}, nil func LogSecurityf(format string, args ...any) {
} log.Security(fmt.Sprintf(format, args...))
}
// --- Package-level convenience ---
// Log returns the CLI's log service, or nil if not available.
func Log() *LogService {
if instance == nil {
return nil
}
svc, err := core.ServiceFor[*LogService](instance.core, "log")
if err != nil {
return nil
}
return svc
}
// LogDebug logs a debug message with optional key-value pairs if log service is available.
func LogDebug(msg string, keyvals ...any) {
if l := Log(); l != nil {
l.Debug(msg, keyvals...)
}
}
// LogInfo logs an info message with optional key-value pairs if log service is available.
func LogInfo(msg string, keyvals ...any) {
if l := Log(); l != nil {
l.Info(msg, keyvals...)
}
}
// LogWarn logs a warning message with optional key-value pairs if log service is available.
func LogWarn(msg string, keyvals ...any) {
if l := Log(); l != nil {
l.Warn(msg, keyvals...)
}
}
// LogError logs an error message with optional key-value pairs if log service is available.
func LogError(msg string, keyvals ...any) {
if l := Log(); l != nil {
l.Error(msg, keyvals...)
}
}
// LogSecurity logs a security message if log service is available.
func LogSecurity(msg string, keyvals ...any) {
if l := Log(); l != nil {
// Ensure user context is included if not already present
hasUser := false
for i := 0; i < len(keyvals); i += 2 {
if keyvals[i] == "user" {
hasUser = true
break
}
}
if !hasUser {
keyvals = append(keyvals, "user", log.Username())
}
l.Security(msg, keyvals...)
}
} }

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

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

Some files were not shown because too many files have changed in this diff Show more