Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 0.11.6 - Unreleased

### Changes

- Improve root, command, and nested command help with descriptions and side-effect-free `-h` handling. Thanks @0xdevalias.

### Fixes

- Show setup guidance instead of a raw missing-file error when configuration-dependent commands cannot find `config.toml`. Thanks @0xdevalias.
Expand Down
127 changes: 115 additions & 12 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ func ExitCode(err error) int {

func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
if len(args) == 0 || rootHelpRequested(args, "config") {
printUsage(stdout)
return nil
return printUsage(stdout)
}
var global discrawlRootArgs
if err := parseKongArgs(&global, args, "discrawl", stdout, stderr); err != nil {
Expand All @@ -62,12 +61,14 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
}
rest := global.Args
if len(rest) == 0 || rest[0] == "--help" || rest[0] == "-h" || (rest[0] == "help" && len(rest) == 1) {
printUsage(stdout)
return nil
return printUsage(stdout)
}
if rest[0] == "help" {
return printCommandUsage(stdout, rest[1:])
}
if topic, ok := earlyCommandHelpTopic(rest); ok {
return printCommandUsage(stdout, topic)
}
if rest[0] == "version" {
_, _ = io.WriteString(stdout, version+"\n")
return nil
Expand All @@ -92,15 +93,117 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runtime.dispatch(rest)
}

type discrawlGlobalArgs struct {
Config string `help:"Config path."`
JSON bool `name:"json" help:"Write JSON output."`
Plain bool `help:"Write stable plain text output when available."`
Quiet bool `short:"q" help:"Only log errors."`
Verbose bool `short:"v" help:"Enable debug logging."`
Version bool `help:"Print version and exit."`
NoColor bool `name:"no-color" help:"Disable color output."`
}

type discrawlRootArgs struct {
Config string `help:"Config path."`
JSON bool `name:"json" help:"Write JSON output."`
Plain bool `help:"Write stable plain text output when available."`
Quiet bool `short:"q" help:"Only log errors."`
Verbose bool `short:"v" help:"Enable debug logging."`
Version bool `help:"Print version and exit."`
NoColor bool `name:"no-color" help:"Disable color output."`
Args []string `arg:"" optional:"" passthrough:"partial" name:"command" help:"Command and arguments."`
discrawlGlobalArgs
Args []string `arg:"" optional:"" passthrough:"partial" name:"command" help:"Command and arguments."`
}

type discrawlHelpArgs struct {
discrawlGlobalArgs
}

type discrawlCommandSpec struct {
name string
description string
}

var discrawlCommandSpecs = []discrawlCommandSpec{
{name: "metadata", description: "Print archive snapshot metadata."},
{name: "check-update", description: "Check GitHub Releases for a newer Discrawl build."},
{name: "version", description: "Print the Discrawl version."},
{name: "init", description: "Initialize Discrawl configuration."},
{name: "sync", description: "Sync Discord data into the local archive."},
{name: "tail", description: "Continuously archive new Discord messages."},
{name: "tap", description: "Import Discord Desktop cache data (wiretap alias)."},
{name: "cache-import", description: "Import Discord Desktop cache data (wiretap alias)."},
{name: "wiretap", description: "Import Discord Desktop cache data."},
{name: "search", description: "Search archived messages."},
{name: "tui", description: "Explore the archive in an interactive terminal UI."},
{name: "messages", description: "List archived messages."},
{name: "digest", description: "Summarize recent archive activity."},
{name: "analytics", description: "Analyze archive activity and trends."},
{name: "dms", description: "List local Discord Desktop conversations."},
{name: "mentions", description: "List archived mentions."},
{name: "attachments", description: "List or fetch archived attachments."},
{name: "embed", description: "Generate embeddings for archived messages."},
{name: "sql", description: "Run SQL queries against the local archive."},
{name: "members", description: "Inspect archived Discord members."},
{name: "channels", description: "Inspect and resolve archived channels."},
{name: "status", description: "Show archive status and freshness."},
{name: "diagnostics", description: "Report SQLite and sync-lock diagnostics."},
{name: "coverage", description: "Report archive coverage."},
{name: "failures", description: "List retained sync and import failures."},
{name: "remote", description: "Access a configured remote archive."},
{name: "whoami", description: "Show the configured remote identity."},
{name: "report", description: "Generate archive reports."},
{name: "publish", description: "Publish the configured archive snapshot."},
{name: "doctor", description: "Check Discrawl configuration and dependencies."},
{name: "cloud", description: "Manage a Cloudflare-backed remote archive."},
{name: "subscribe", description: "Configure a read-only snapshot subscription."},
{name: "subscribe-cloud", description: "Configure a read-only cloud archive."},
{name: "update", description: "Update the configured archive snapshot."},
}

func newDiscrawlHelpParser(stdout io.Writer) (*kong.Kong, error) {
var root discrawlHelpArgs
var command struct{}
options := []kong.Option{
kong.Name("discrawl"),
kong.Description("discrawl archives Discord guild data into local SQLite."),
kong.Writers(stdout, io.Discard),
kong.Exit(func(int) {}),
kong.ConfigureHelp(kong.HelpOptions{Compact: true, NoExpandSubcommands: true}),
}
for _, spec := range discrawlCommandSpecs {
options = append(options, kong.DynamicCommand(spec.name, spec.description, "", &command))
}
return kong.New(&root, options...)
}

func printKongUsage(stdout io.Writer) error {
parser, err := newDiscrawlHelpParser(stdout)
if err != nil {
return err
}
_, _ = parser.Parse([]string{"--help"})
return nil
}

func earlyCommandHelpTopic(rest []string) ([]string, bool) {
if len(rest) < 2 || rest[0] == "tui" || !hasHelpFlag(rest[1:]) {
return nil, false
}
if len(rest) >= 3 {
topic := canonicalHelpTopic(strings.Join(rest[:2], " "))
if _, ok := commandUsage[topic]; ok {
return strings.Fields(topic), true
}
}
topic := canonicalHelpTopic(rest[0])
if _, ok := commandUsage[topic]; !ok {
return nil, false
}
return []string{topic}, true
}

func canonicalHelpTopic(topic string) string {
if topic == "tap" || topic == "cache-import" {
return "wiretap"
}
if topic == "attachments fetch" {
return "attachments"
}
return topic
}

func rootHelpRequested(args []string, valueFlags ...string) bool {
Expand Down
88 changes: 87 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ func TestHelpAndVersion(t *testing.T) {
require.Equal(t, 7, ExitCode(&cliError{code: 7, err: errors.New("custom")}))
}

func TestTopLevelHelpIncludesDescriptions(t *testing.T) {
t.Parallel()

for _, helpFlag := range []string{"-h", "--help"} {
var stdout, stderr bytes.Buffer
require.NoError(t, Run(context.Background(), []string{helpFlag}, &stdout, &stderr))
require.Contains(t, stdout.String(), "wiretap")
require.Contains(t, stdout.String(), "Import Discord Desktop cache data.")
require.Contains(t, stdout.String(), "search")
require.Contains(t, stdout.String(), "Search archived messages.")
require.Empty(t, stderr.String())
}
}

func TestCommandValidationEdges(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
Expand Down Expand Up @@ -4051,22 +4065,82 @@ func TestCommandHelpDoesNotOpenConfigOrStore(t *testing.T) {

for _, args := range [][]string{
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "help", "search"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "help", "wiretap"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "search", "--help"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "messages", "--help"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "sql", "--help"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "coverage", "--help"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "wiretap", "-h"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "wiretap", "--help"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "sync", "--full", "--help"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "search", "term", "--help"},
{"--config", filepath.Join(t.TempDir(), "missing.toml"), "remote", "login", "--endpoint", "https://example.invalid", "--help"},
} {
var stdout, stderr bytes.Buffer
require.NoError(t, Run(context.Background(), args, &stdout, &stderr), "args=%v", args)
require.Contains(t, stdout.String(), "Usage:", "args=%v", args)
require.Contains(t, stdout.String(), "Usage", "args=%v", args)
require.Empty(t, stderr.String(), "args=%v", args)
}

for _, spec := range discrawlCommandSpecs {
args := []string{"--config", filepath.Join(t.TempDir(), "missing.toml"), spec.name, "--help"}
var stdout, stderr bytes.Buffer
require.NoError(t, Run(context.Background(), args, &stdout, &stderr), "args=%v", args)
require.Contains(t, stdout.String(), "Usage", "args=%v", args)
require.Empty(t, stderr.String(), "args=%v", args)
}

var stdout, stderr bytes.Buffer
require.NoError(t, Run(context.Background(), []string{"wiretap", "--help"}, &stdout, &stderr))
require.Contains(t, stdout.String(), "--path PATH")
require.Contains(t, stdout.String(), "--watch-every DURATION")
require.Contains(t, stdout.String(), "--stats")
require.Empty(t, stderr.String())

err := Run(context.Background(), []string{"help", "wat"}, &bytes.Buffer{}, &bytes.Buffer{})
require.Error(t, err)
require.Contains(t, err.Error(), `unknown help topic "wat"`)
}

func TestNestedCommandHelp(t *testing.T) {
t.Parallel()

for _, args := range [][]string{
{"analytics", "-h"},
{"analytics", "--help"},
{"help", "analytics"},
} {
var stdout, stderr bytes.Buffer
require.NoError(t, Run(context.Background(), args, &stdout, &stderr), "args=%v", args)
require.Contains(t, stdout.String(), "Usage: discrawl analytics <quiet|trends> [flags]", "args=%v", args)
require.Contains(t, stdout.String(), "quiet", "args=%v", args)
require.Contains(t, stdout.String(), "trends", "args=%v", args)
require.Empty(t, stderr.String(), "args=%v", args)
}

for _, args := range [][]string{
{"analytics", "quiet", "--help"},
{"help", "analytics", "quiet"},
} {
var stdout, stderr bytes.Buffer
require.NoError(t, Run(context.Background(), args, &stdout, &stderr), "args=%v", args)
require.Contains(t, stdout.String(), "Usage: discrawl analytics quiet", "args=%v", args)
require.Contains(t, stdout.String(), "no activity in the lookback window", "args=%v", args)
require.Empty(t, stderr.String(), "args=%v", args)
}
}

func TestHelpFlagAfterDelimiterReachesCommand(t *testing.T) {
t.Parallel()

for _, helpFlag := range []string{"-h", "--help"} {
var stdout, stderr bytes.Buffer
require.NoError(t, Run(context.Background(), []string{"version", "--", helpFlag}, &stdout, &stderr))
require.Equal(t, version+"\n", stdout.String())
require.Empty(t, stderr.String())
}
}

func TestHelpers(t *testing.T) {
t.Parallel()

Expand All @@ -4091,6 +4165,18 @@ func TestHelpers(t *testing.T) {
require.True(t, hybridSemanticUnavailable(store.ErrNoCompatibleEmbeddings))
require.True(t, hybridSemanticUnavailable(assertErr("semantic query embedding missing")))
require.False(t, hybridSemanticUnavailable(assertErr("other")))
topic, ok := earlyCommandHelpTopic([]string{"wiretap", "--help"})
require.True(t, ok)
require.Equal(t, []string{"wiretap"}, topic)
topic, ok = earlyCommandHelpTopic([]string{"sync", "--help"})
require.True(t, ok)
require.Equal(t, []string{"sync"}, topic)
topic, ok = earlyCommandHelpTopic([]string{"remote", "login", "--help"})
require.True(t, ok)
require.Equal(t, []string{"remote", "login"}, topic)
topic, ok = earlyCommandHelpTopic([]string{"remote", "login", "--endpoint", "https://example.invalid", "--help"})
require.True(t, ok)
require.Equal(t, []string{"remote", "login"}, topic)
opts, err := shareOptionsFromFlags("~/share", "git@example.com:org/archive.git", "")
require.NoError(t, err)
require.Equal(t, "git@example.com:org/archive.git", opts.Remote)
Expand Down
Loading