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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ You need `curl`, `tar`, and `sha256sum`. For manual downloads, upgrades,
non-root installation, and removal, see the [installation guide](docs/user/INSTALLATION.md).
Configuration is stored at `~/.config/calmstoolkit/config.json` with mode
`0600`; override it with `--config` or `CALMSTOOLKIT_CONFIG`.
`calmstoolkit config setup` is both a first-run wizard and a configuration
editor. Choose a numbered section to update one feature, `A` to walk through
every section, or `S` to validate and save. Existing values are retained when
you press Enter, optional values can be cleared with `-`, and credentials are
never displayed. Sonarr and Radarr screens support adding, editing, and removing
multiple instances.
For Sonarr and Radarr instances, `url` is the address CalmsToolkit uses for API
requests. Set the optional `external_url` when browser links must use a different
address, such as when the API URL contains a container-only hostname.
Expand All @@ -50,12 +56,30 @@ calmstoolkit airtime <query> Library airtime lookup
calmstoolkit feed Sonarr/Radarr activity
calmstoolkit anime <query> AniList search with TVDB mapping
calmstoolkit config setup|validate Configuration management
calmstoolkit completion <shell> Generate shell completions
calmstoolkit doctor Local and service diagnostics
calmstoolkit version Build information
```

Run `calmstoolkit <command> --help` for feature flags. Global flags are `--config`, `--output`, `--theme`, `--no-color`, `--timeout`, `--debug`, `--quiet`, and `--strict`.

## Shell completions

Completion scripts include commands, subcommands, local and persistent flags,
and known values for flags such as `--output`, `--theme`, `--server`, and
`--type`. Generate a script for bash, zsh, or fish:

```bash
# Bash (user installation; create the directory if needed)
calmstoolkit completion bash > ~/.local/share/bash-completion/completions/calmstoolkit

# Zsh (the target directory must be in fpath)
calmstoolkit completion zsh > "${fpath[1]}/_calmstoolkit"

# Fish
calmstoolkit completion fish > ~/.config/fish/completions/calmstoolkit.fish
```

Output is `auto`, `terminal`, `plain`, `json`, or `ndjson`. Auto uses the rich terminal view only on a capable UTF-8 TTY and otherwise selects plain output. Watch commands require NDJSON rather than JSON for machine output. Diagnostics always go to stderr.

```bash
Expand Down
41 changes: 41 additions & 0 deletions docs/user/CLI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,47 @@

Configuration precedence is `defaults < configuration file < environment < explicitly supplied flags`. An omitted boolean or interval flag never overwrites its configured value. The config path is explicit `--config`, then `CALMSTOOLKIT_CONFIG`, then `~/.config/calmstoolkit/config.json`.

## Configuration setup

`calmstoolkit config setup` is the canonical onboarding and editing interface.
Its menu supports editing one section, running every section in sequence,
validating and saving, or quitting without saving. Pressing Enter retains the
displayed value; `-` clears an optional value. Input is validated before the
flow advances. Secrets are displayed as `configured`, and environment-only
credentials are not copied into the configuration file. `--force` starts from
defaults, while an existing file is otherwise edited in place. `--defaults`
writes defaults non-interactively (`--force` is required to replace a file).

The setup flow exposes every persisted user setting:

| Section | Settings |
|---|---|
| General | HTTP timeout, color disablement, theme |
| Sonarr instances | name, API URL, optional external/browser URL, API key; add/edit/remove multiple instances |
| Radarr instances | name, API URL, optional external/browser URL, API key; add/edit/remove multiple instances |
| Media streams | enabled server type, Plex URL/token, Jellyfin URL/token, watch interval, history duration |
| Media requests | Overseerr/Jellyseerr URL, API key, verbose diagnostics |
| Media calendar | future days, past days, watch interval, debug diagnostics |
| Media airtime | result limit, past-day window, future-day window, debug diagnostics |
| Arr feed | poll interval, history window, grabbed/imported/failed/deleted/ignored/subtitle visibility, maximum events |
| AniSearch | mapping download URL, optional cache path, result limit |

`version` is managed by the application and is not prompted. Credentials can
instead be overlaid with `CALMSTOOLKIT_PLEX_TOKEN`,
`CALMSTOOLKIT_JELLYFIN_TOKEN`, `CALMSTOOLKIT_REQUESTS_API_KEY`, and per-instance
`CALMSTOOLKIT_SONARR_<NORMALIZED_NAME>_API_KEY` or
`CALMSTOOLKIT_RADARR_<NORMALIZED_NAME>_API_KEY`. Legacy `PLEX_TOKEN`,
`JELLYFIN_TOKEN`, and `OVERSEERR_API_KEY` remain fallback names.

## Completion

`calmstoolkit completion <shell>` writes a Cobra-generated completion script to
stdout. Supported shells are `bash`, `zsh`, and `fish`. The scripts
discover the complete command tree, local and persistent flags, and registered
values for finite options including output mode, theme, stream server, and
airtime search type. See the [installation guide](INSTALLATION.md#shell-completions)
for installation commands.

Sonarr and Radarr instances distinguish the API address from the optional
browser-facing address:

Expand Down
42 changes: 41 additions & 1 deletion docs/user/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,47 @@ calmstoolkit doctor

The configuration is written to `~/.config/calmstoolkit/config.json` with
permissions `0600`. Use `--config` or `CALMSTOOLKIT_CONFIG` to select a different
file.
file. Setup opens a section menu: select one feature to edit, choose `A` for the
complete guided onboarding flow, `S` to validate and save, or `Q` to leave the
file unchanged. Enter accepts the displayed value and `-` clears an optional
value. Existing credentials are shown only as `configured`.

## Shell completions

The generated scripts cover commands, subcommands, flags, persistent flags, and
known option values. Create the destination directory first if it does not
already exist.

### Bash

For a user-local installation of `bash-completion`:

```bash
mkdir -p ~/.local/share/bash-completion/completions
calmstoolkit completion bash > ~/.local/share/bash-completion/completions/calmstoolkit
```

Start a new shell after installation. Distribution-wide completion directories
vary; `/usr/share/bash-completion/completions/` is common.

### Zsh

Write the script to a directory already present in `fpath`, then start a new
shell (or run `compinit`):

```zsh
calmstoolkit completion zsh > "${fpath[1]}/_calmstoolkit"
autoload -U compinit && compinit
```

### Fish

```bash
mkdir -p ~/.config/fish/completions
calmstoolkit completion fish > ~/.config/fish/completions/calmstoolkit.fish
```

Fish discovers the file automatically in new and current sessions.

## Upgrade

Expand Down
44 changes: 44 additions & 0 deletions internal/cli/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cli

import (
"fmt"

"github.com/calmcacil/CalmsToolkit/internal/app"
"github.com/spf13/cobra"
)

var completionShells = []string{"bash", "zsh", "fish"}

func fixedCompletions(values ...string) cobra.CompletionFunc {
return func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return values, cobra.ShellCompDirectiveNoFileComp
}
}

func newCompletionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "completion <shell>",
Short: "Generate a shell completion script",
Long: "Generate a completion script for bash, zsh, or fish and write it to stdout.",
Args: cobra.ExactArgs(1),
ValidArgs: completionShells,
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error {
root := cmd.Root()
switch args[0] {
case "bash":
return root.GenBashCompletionV2(cmd.OutOrStdout(), true)
case "zsh":
return root.GenZshCompletion(cmd.OutOrStdout())
case "fish":
return root.GenFishCompletion(cmd.OutOrStdout(), true)
default:
return app.Error(app.ExitUsage, fmt.Errorf("unsupported shell %q (choose bash, zsh, or fish)", args[0]))
}
},
}
cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return completionShells, cobra.ShellCompDirectiveNoFileComp
}
return cmd
}
111 changes: 32 additions & 79 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@
package cli

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -38,11 +36,16 @@ type globalOptions struct {
func NewRootCommand(rt *app.Runtime) *cobra.Command {
var global globalOptions
root := &cobra.Command{
Use: "calmstoolkit",
Short: "A unified SSH-friendly media toolkit",
SilenceErrors: true,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { return configureRuntime(cmd, rt, global) },
Use: "calmstoolkit",
Short: "A unified SSH-friendly media toolkit",
SilenceErrors: true,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
if cmd.Name() == "completion" || strings.HasPrefix(cmd.Name(), "__complete") {
return nil
}
return configureRuntime(cmd, rt, global)
},
}
root.SetIn(rt.Stdin)
root.SetOut(rt.Stdout)
Expand All @@ -56,7 +59,9 @@ func NewRootCommand(rt *app.Runtime) *cobra.Command {
f.BoolVar(&global.debug, "debug", false, "enable redacted diagnostics")
f.BoolVar(&global.quiet, "quiet", false, "suppress informational diagnostics")
f.BoolVar(&global.strict, "strict", false, "fail with status 3 on partial results")
root.AddCommand(newStreamsCommand(rt), newCalendarCommand(rt), newRequestsCommand(rt), newAirtimeCommand(rt), newFeedCommand(rt), newAnimeCommand(rt), newConfigCommand(rt), newDoctorCommand(rt), newVersionCommand(rt))
_ = root.RegisterFlagCompletionFunc("output", fixedCompletions("auto", "terminal", "plain", "json", "ndjson"))
_ = root.RegisterFlagCompletionFunc("theme", fixedCompletions("default", "catppuccin-mocha", "catppuccin-latte"))
root.AddCommand(newStreamsCommand(rt), newCalendarCommand(rt), newRequestsCommand(rt), newAirtimeCommand(rt), newFeedCommand(rt), newAnimeCommand(rt), newConfigCommand(rt), newCompletionCommand(), newDoctorCommand(rt), newVersionCommand(rt))
return root
}

Expand Down Expand Up @@ -204,6 +209,7 @@ func newStreamsCommand(rt *app.Runtime) *cobra.Command {
f.BoolVar(&watch, "watch", false, "continuously monitor")
f.IntVar(&interval, "interval", 0, "watch interval in seconds")
f.DurationVar(&history, "history-duration", 0, "session history duration")
_ = cmd.RegisterFlagCompletionFunc("server", fixedCompletions("plex", "jellyfin", "both"))
return cmd
}

Expand Down Expand Up @@ -322,6 +328,7 @@ func newAirtimeCommand(rt *app.Runtime) *cobra.Command {
f.IntVar(&future, "future", 0, "future days")
f.BoolVar(&noBanner, "no-banner", false, "suppress banner")
f.BoolVar(&full, "full-season", false, "show full season")
_ = cmd.RegisterFlagCompletionFunc("type", fixedCompletions("auto", "series", "movie"))
return cmd
}

Expand Down Expand Up @@ -419,17 +426,30 @@ func newConfigCommand(rt *app.Runtime) *cobra.Command {
return nil
}}
var force, defaults bool
setup := &cobra.Command{Use: "setup", Args: cobra.NoArgs, Short: "Create a secure default configuration", RunE: func(*cobra.Command, []string) error {
setup := &cobra.Command{Use: "setup", Args: cobra.NoArgs, Short: "Interactively configure every CalmsToolkit feature", Long: "Guide the user through general settings, services, feature defaults, and paths, then securely save the complete configuration.", RunE: func(*cobra.Command, []string) error {
if rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON {
return app.Error(app.ExitUsage, errors.New("config setup is interactive and does not support JSON or NDJSON output"))
}
_, statErr := os.Stat(rt.ConfigPath)
if statErr == nil && defaults && !force {
return app.Error(app.ExitUsage, fmt.Errorf("configuration already exists at %s (use --force to replace)", rt.ConfigPath))
}
cfg := rt.Config
var cfg *config.ToolkitConfig
if force || statErr != nil {
cfg = config.DefaultToolkitConfig()
} else {
var err error
cfg, err = config.LoadPersistedToolkitConfigAt(rt.ConfigPath)
if err != nil {
return err
}
}
if !defaults {
if err := promptSetup(rt, cfg); err != nil {
if errors.Is(err, errSetupCancelled) {
fmt.Fprintln(rt.Stderr, "Setup cancelled; no changes were saved.")
return nil
}
return app.Error(app.ExitUsage, err)
}
}
Expand All @@ -439,82 +459,15 @@ func newConfigCommand(rt *app.Runtime) *cobra.Command {
if err := cfg.SaveAt(rt.ConfigPath); err != nil {
return err
}
fmt.Fprintf(rt.Stdout, "Created %s with mode 0600. Add service credentials, then run 'calmstoolkit config validate'.\n", rt.ConfigPath)
fmt.Fprintf(rt.Stdout, "Saved configuration to %s with mode 0600. Run 'calmstoolkit config validate' or 'calmstoolkit doctor' to verify it.\n", rt.ConfigPath)
return nil
}}
setup.Flags().BoolVar(&force, "force", false, "replace existing configuration")
setup.Flags().BoolVar(&defaults, "defaults", false, "write defaults without prompting")
setup.Flags().BoolVar(&defaults, "defaults", false, "write all defaults without prompting")
parent.AddCommand(setup, validate)
return parent
}

func promptSetup(rt *app.Runtime, cfg *config.ToolkitConfig) error {
scanner := bufio.NewScanner(rt.Stdin)
prompt := func(label, current string) (string, error) {
if current == "" {
fmt.Fprintf(rt.Stdout, "%s: ", label)
} else {
display := current
lower := strings.ToLower(label)
if strings.Contains(lower, "token") || strings.Contains(lower, "key") {
display = "configured"
}
fmt.Fprintf(rt.Stdout, "%s [%s]: ", label, display)
}
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("input ended while reading %s", label)
}
value := strings.TrimSpace(scanner.Text())
if value == "" {
return current, nil
}
return value, nil
}
var err error
if cfg.General.Timeout, err = prompt("HTTP timeout", cfg.General.Timeout); err != nil {
return err
}
if cfg.General.Theme, err = prompt("Theme", cfg.General.Theme); err != nil {
return err
}
if cfg.MediaStreams.PlexURL, err = prompt("Plex URL", cfg.MediaStreams.PlexURL); err != nil {
return err
}
if cfg.MediaStreams.PlexToken, err = prompt("Plex token", cfg.MediaStreams.PlexToken); err != nil {
return err
}
if cfg.MediaStreams.JellyfinURL, err = prompt("Jellyfin URL", cfg.MediaStreams.JellyfinURL); err != nil {
return err
}
if cfg.MediaStreams.JellyfinToken, err = prompt("Jellyfin token", cfg.MediaStreams.JellyfinToken); err != nil {
return err
}
if cfg.MediaRequests.OverseerrURL, err = prompt("Overseerr/Jellyseerr URL", cfg.MediaRequests.OverseerrURL); err != nil {
return err
}
if cfg.MediaRequests.APIKey, err = prompt("Requests API key", cfg.MediaRequests.APIKey); err != nil {
return err
}
if cfg.AniSearch.MappingURL, err = prompt("AniSearch mapping URL", cfg.AniSearch.MappingURL); err != nil {
return err
}
if cfg.AniSearch.MappingPath, err = prompt("AniSearch mapping cache path", cfg.AniSearch.MappingPath); err != nil {
return err
}
limit, err := prompt("AniSearch result limit", strconv.Itoa(cfg.AniSearch.Limit))
if err != nil {
return err
}
cfg.AniSearch.Limit, err = strconv.Atoi(limit)
if err != nil {
return fmt.Errorf("AniSearch result limit: %w", err)
}
return nil
}

type doctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Expand Down
Loading