Skip to content

Commit 2ef1996

Browse files
authored
Merge pull request #207 from flashcatcloud/fix/invoked-command-name
Name the CLI after the command word it was invoked as
2 parents 12d801c + 543a68b commit 2ef1996

46 files changed

Lines changed: 481 additions & 347 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/flashduty/main.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"runtime/debug"
78
"strings"
89

@@ -29,7 +30,17 @@ func main() {
2930
date = "unknown"
3031
}
3132
cli.SetVersionInfo(version, commit, date)
32-
if err := cli.Execute(); err != nil {
33+
// The CLI names itself after the command word the user typed, so a copy
34+
// installed under another name (install.sh INSTALLED_NAME) shows that name
35+
// in help, errors and completion scripts.
36+
name := filepath.Base(os.Args[0])
37+
if ext := filepath.Ext(name); strings.EqualFold(ext, ".exe") {
38+
name = strings.TrimSuffix(name, ext)
39+
}
40+
if name == "" || name == "." || name == string(filepath.Separator) {
41+
name = "flashduty"
42+
}
43+
if err := cli.Execute(name); err != nil {
3344
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
3445
os.Exit(1)
3546
}

‎install.ps1‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Environment variables:
55
# FLASHDUTY_VERSION - specific version to install (e.g. "v0.1.2")
66
# FLASHDUTY_INSTALL_DIR - install directory (default: $HOME\.flashduty\bin)
7+
# INSTALLED_NAME - installed command name (default: flashduty)
78
# MIRROR_URL - fetch release assets from this https mirror prefix.
89
# Default: https://static.flashcat.cloud/flashduty-cli.
910
# The mirror must replicate
@@ -17,7 +18,8 @@ $ErrorActionPreference = "Stop"
1718

1819
$Repo = "flashcatcloud/flashduty-cli"
1920
$Binary = "flashduty-cli.exe"
20-
$InstalledName = "flashduty.exe"
21+
$CommandName = if ($env:INSTALLED_NAME) { $env:INSTALLED_NAME -replace '\.exe$', '' } else { "flashduty" }
22+
$InstalledName = "$CommandName.exe"
2123

2224
# By default release downloads are fetched from the Flashcat CDN. Set MIRROR_URL
2325
# to another prefix to override, or to an empty string to force GitHub fallback.
@@ -153,6 +155,13 @@ try {
153155
}
154156

155157
$DestPath = Join-Path $InstallDir $InstalledName
158+
if (Test-Path $DestPath) {
159+
# A running .exe can't be overwritten but can be renamed: move it aside
160+
# so `update` can replace the binary it is running from.
161+
$OldPath = "$DestPath.old"
162+
Remove-Item -Path $OldPath -Force -ErrorAction SilentlyContinue
163+
Move-Item -Path $DestPath -Destination $OldPath -Force
164+
}
156165
Move-Item -Path $BinaryPath -Destination $DestPath -Force
157166

158167
Write-Info "Installed to $DestPath"
@@ -165,7 +174,7 @@ try {
165174
Write-Info "Added $InstallDir to user PATH (restart your terminal for it to take effect)"
166175
}
167176

168-
Write-Info "Run 'flashduty version' to verify"
177+
Write-Info "Run '$CommandName version' to verify"
169178
} finally {
170179
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
171180
}

‎install.sh‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Environment:
66
# FLASHDUTY_VERSION Install a specific version (e.g. v0.1.2). Default: latest.
77
# FLASHDUTY_INSTALL_DIR Install directory. Default: /usr/local/bin.
8+
# INSTALLED_NAME Installed command name. Default: flashduty.
89
# MIRROR_URL Fetch release assets from this https mirror prefix.
910
# Default: https://static.flashcat.cloud/flashduty-cli.
1011
# The mirror must replicate
@@ -134,17 +135,6 @@ detect_shell() {
134135
esac
135136
}
136137

137-
# Emit the completion script for the shell named in $1. Cobra bakes the root
138-
# command name "flashduty" into the script (#compdef / complete -c / function
139-
# names); when installed under a different name, rewrite every occurrence so the
140-
# completion binds to the actual command (the runtime dispatch line already uses
141-
# the typed command word, so it needs no rewrite). The `|` sed delimiter is safe
142-
# because a binary name can't contain it, and the rewrite is a no-op for the
143-
# default "flashduty".
144-
gen_completion() {
145-
"${BIN}" completion "$1" | sed "s|flashduty|${INSTALLED_NAME}|g"
146-
}
147-
148138
# Install completion for the current shell into a directory the shell already
149139
# auto-loads, without ever editing the user's rc files. zsh has no guaranteed
150140
# writable fpath dir, so it only succeeds when a standard site-functions dir is
@@ -161,7 +151,7 @@ setup_completion() {
161151
dir="${XDG_CONFIG_HOME:-${HOME}/.config}/fish/completions"
162152
mkdir -p "${dir}" 2>/dev/null || true
163153
if [ -w "${dir}" ]; then
164-
gen_completion fish > "${dir}/${INSTALLED_NAME}.fish" && {
154+
"${BIN}" completion fish > "${dir}/${INSTALLED_NAME}.fish" && {
165155
info "Installed fish completion to ${dir}/${INSTALLED_NAME}.fish (restart fish to load)"
166156
return 0
167157
}
@@ -171,7 +161,7 @@ setup_completion() {
171161
dir="${XDG_DATA_HOME:-${HOME}/.local/share}/bash-completion/completions"
172162
mkdir -p "${dir}" 2>/dev/null || true
173163
if [ -w "${dir}" ]; then
174-
gen_completion bash > "${dir}/${INSTALLED_NAME}" && {
164+
"${BIN}" completion bash > "${dir}/${INSTALLED_NAME}" && {
175165
info "Installed bash completion to ${dir}/${INSTALLED_NAME} (needs the bash-completion package; restart bash to load)"
176166
return 0
177167
}
@@ -183,7 +173,7 @@ setup_completion() {
183173
"/usr/local/share/zsh/site-functions" \
184174
"/usr/share/zsh/site-functions"; do
185175
if [ -d "${dir}" ] && [ -w "${dir}" ]; then
186-
gen_completion zsh > "${dir}/_${INSTALLED_NAME}" && {
176+
"${BIN}" completion zsh > "${dir}/_${INSTALLED_NAME}" && {
187177
info "Installed zsh completion to ${dir}/_${INSTALLED_NAME}"
188178
info " Run 'rm -f ~/.zcompdump*' and restart zsh to load."
189179
return 0

‎internal/cli/audit.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func newAuditSearchCmd() *cobra.Command {
104104
cmd.Flags().StringVar(&since, "since", "7d", "Start time")
105105
cmd.Flags().StringVar(&until, "until", "now", "End time")
106106
cmd.Flags().Int64Var(&person, "person", 0, "Filter by person ID")
107-
cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'fduty audit operation-list' (e.g. monitRule:write:update); comma-separate to match several in one call. Prefixes do NOT match (\"monitRule\" returns nothing).")
107+
cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'flashduty audit operation-list' (e.g. monitRule:write:update); comma-separate to match several in one call. Prefixes do NOT match (\"monitRule\" returns nothing).")
108108
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 99)")
109109
cmd.Flags().IntVar(&page, "page", 1, "Page number")
110110

‎internal/cli/incident.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func newIncidentListCmd() *cobra.Command {
8080
cmd := &cobra.Command{
8181
Use: "list",
8282
Short: "List incidents",
83-
Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, rows default to the compact fields incident_id,num,title,incident_severity,progress,start_time,channel_id,detail_url; pass --fields to choose a different projection.\n\nSee also: fduty insight <team|responder|channel> for aggregated metrics (MTTA, MTTR, noise reduction), fduty insight incident-list for metric-rich filtered incident rows, and fduty insight incident-export for CSV incident exports.", "Incidents", "List"),
83+
Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, rows default to the compact fields incident_id,num,title,incident_severity,progress,start_time,channel_id,detail_url; pass --fields to choose a different projection.\n\nSee also: flashduty insight <team|responder|channel> for aggregated metrics (MTTA, MTTR, noise reduction), flashduty insight incident-list for metric-rich filtered incident rows, and flashduty insight incident-export for CSV incident exports.", "Incidents", "List"),
8484
RunE: func(cmd *cobra.Command, args []string) error {
8585
return runCommand(cmd, args, func(ctx *RunContext) error {
8686
startTime, err := timeutil.Parse(since)
@@ -1004,7 +1004,7 @@ const maxIncidentVerifyConcurrency = 8
10041004
// symbol instead of pinning a copy of the sentence: reword this constant and
10051005
// every caller (production and test) picks up the new wording automatically,
10061006
// with nothing left to fall out of sync.
1007-
const commentVerificationGuidance = "The write API already reported success for the whole batch (a single POST covering every requested incident), and not finding a match here does not mean a comment is missing — this check can only confirm presence, never confirm absence. Do not write the comment again, for the full batch or for the incident(s) listed above alone: either risks a duplicate on a write that most likely already landed. Run `fduty incident timeline <id>` on the listed incident(s) to check by hand before deciding on anything further"
1007+
const commentVerificationGuidance = "The write API already reported success for the whole batch (a single POST covering every requested incident), and not finding a match here does not mean a comment is missing — this check can only confirm presence, never confirm absence. Do not write the comment again, for the full batch or for the incident(s) listed above alone: either risks a duplicate on a write that most likely already landed. Run `flashduty incident timeline <id>` on the listed incident(s) to check by hand before deciding on anything further"
10081008

10091009
// commentVerificationNotFoundDetailFmt is the per-incident detail appended to
10101010
// commentVerificationGuidance's problem list when the page walk completes

‎internal/cli/incident_summary_script_test.go‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ func TestIncidentSummaryScriptCompactOutput(t *testing.T) {
1919
t.Fatalf("resolve repository root: %v", err)
2020
}
2121
script := filepath.Join(root, "skills", "flashduty", "scripts", "incident-summary.sh")
22-
log := filepath.Join(t.TempDir(), "fduty.log")
23-
bin := filepath.Join(t.TempDir(), "fduty")
24-
if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil {
25-
t.Fatalf("write fake fduty: %v", err)
22+
log := filepath.Join(t.TempDir(), "flashduty.log")
23+
bin := filepath.Join(t.TempDir(), "flashduty")
24+
if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FAKE_FLASHDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil {
25+
t.Fatalf("write fake flashduty: %v", err)
2626
}
27-
t.Setenv("FDUTY_LOG", log)
27+
t.Setenv("FAKE_FLASHDUTY_LOG", log)
2828
t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH"))
2929

3030
output, err := exec.Command("bash", script, "inc-1").CombinedOutput()
@@ -33,11 +33,11 @@ func TestIncidentSummaryScriptCompactOutput(t *testing.T) {
3333
}
3434
invocations, err := os.ReadFile(log)
3535
if err != nil {
36-
t.Fatalf("read fake fduty log: %v", err)
36+
t.Fatalf("read fake flashduty log: %v", err)
3737
}
3838
lines := strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' })
3939
if len(lines) != 7 {
40-
t.Fatalf("fduty calls = %d, want 7 (six reads + the start_time probe):\n%s", len(lines), invocations)
40+
t.Fatalf("flashduty calls = %d, want 7 (six reads + the start_time probe):\n%s", len(lines), invocations)
4141
}
4242
wantDetail := "incident detail inc-1 --fields incident_id,num,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id,detail_url --output-format toon"
4343
if lines[0] != wantDetail {

‎internal/cli/incident_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func TestCommandIncidentListHelpSurfacesInsightIncidentExport(t *testing.T) {
106106
if err != nil {
107107
t.Fatalf("incident list --help: %v", err)
108108
}
109-
if !strings.Contains(out, "fduty insight incident-export") {
109+
if !strings.Contains(out, "flashduty insight incident-export") {
110110
t.Fatalf("help output missing incident export discovery hint:\n%s", out)
111111
}
112112
}

‎internal/cli/oncall.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func newOncallWhoCmd() *cobra.Command {
3333
cmd := &cobra.Command{
3434
Use: "who",
3535
Short: "Show who is currently on call",
36-
Long: curatedLong("Show who is currently on call across schedules within a time window, optionally filtered by team or schedule name. The table output already resolves person_ids to display names; when you have raw person_ids elsewhere, batch-resolve them with 'fduty person infos <person_id> ...' (NOT by paginating 'fduty member list' — person_id and member_id are different id namespaces).", "Schedules", "List"),
36+
Long: curatedLong("Show who is currently on call across schedules within a time window, optionally filtered by team or schedule name. The table output already resolves person_ids to display names; when you have raw person_ids elsewhere, batch-resolve them with 'flashduty person infos <person_id> ...' (NOT by paginating 'flashduty member list' — person_id and member_id are different id namespaces).", "Schedules", "List"),
3737
RunE: func(cmd *cobra.Command, args []string) error {
3838
return runCommand(cmd, args, func(ctx *RunContext) error {
3939
startTime, err := timeutil.Parse(since)

‎internal/cli/root.go‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ package cli
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"os"
10+
"regexp"
911
"strconv"
1012
"strings"
1113

1214
"github.com/flashcatcloud/go-flashduty"
1315
"github.com/spf13/cobra"
16+
"github.com/spf13/pflag"
1417
toon "github.com/toon-format/toon-go"
1518
"golang.org/x/term"
1619

@@ -57,7 +60,7 @@ var rootCmd = &cobra.Command{
5760
}
5861
updateNotice = nil
5962
updateCheckWarning = ""
60-
if cmd.CommandPath() == "flashduty update" {
63+
if cmd.CommandPath() == cmd.Root().Name()+" update" {
6164
return nil
6265
}
6366
if update.IsManagedByRunner() {
@@ -70,7 +73,7 @@ var rootCmd = &cobra.Command{
7073
result, err := checkForUpdateAutoFn(versionStr)
7174
if err != nil {
7275
if update.IsTimeout(err) {
73-
updateCheckWarning = "auto update check timeout, please run 'flashduty update --check' manually"
76+
updateCheckWarning = "auto update check timeout, please run '" + cmd.Root().Name() + " update --check' manually"
7477
} else {
7578
updateNotice = update.StateHasUpdate(versionStr)
7679
}
@@ -89,9 +92,9 @@ var rootCmd = &cobra.Command{
8992
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\n%s\n", updateCheckWarning)
9093
}
9194
if updateNotice != nil {
92-
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nA new version of flashduty is available: v%s -> %s\n",
93-
update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion)
94-
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "To update, run: flashduty update\n")
95+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nA new version of %s is available: v%s -> %s\n",
96+
cmd.Root().Name(), update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion)
97+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "To update, run: %s update\n", cmd.Root().Name())
9598
}
9699
},
97100
}
@@ -162,9 +165,44 @@ func init() {
162165
attachSafariSkillUpload(rootCmd)
163166
}
164167

165-
// Execute runs the root command.
166-
func Execute() error {
167-
return rootCmd.Execute()
168+
// Execute runs the root command as name, the command word the binary was
169+
// invoked as. Help and error text is authored with the canonical "flashduty";
170+
// under any other name the tree's text and the returned error are rewritten
171+
// to it, and cobra derives completion scripts from the root's name.
172+
func Execute(name string) error {
173+
if name == rootCmd.Name() {
174+
return rootCmd.Execute()
175+
}
176+
renameCommandText(rootCmd, name)
177+
rootCmd.Use = name
178+
if err := rootCmd.Execute(); err != nil {
179+
return errors.New(renameCLI(err.Error(), name))
180+
}
181+
return nil
182+
}
183+
184+
// renameCommandText rewrites the CLI name in cmd's help text and flag usages,
185+
// recursively.
186+
func renameCommandText(cmd *cobra.Command, name string) {
187+
cmd.Short = renameCLI(cmd.Short, name)
188+
cmd.Long = renameCLI(cmd.Long, name)
189+
cmd.Example = renameCLI(cmd.Example, name)
190+
rename := func(f *pflag.Flag) { f.Usage = renameCLI(f.Usage, name) }
191+
cmd.Flags().VisitAll(rename)
192+
cmd.PersistentFlags().VisitAll(rename)
193+
for _, c := range cmd.Commands() {
194+
renameCommandText(c, name)
195+
}
196+
}
197+
198+
// cliWord matches "flashduty" used as a command word: followed by a space, and
199+
// not part of a path, domain or longer identifier ("go-flashduty",
200+
// "~/.flashduty").
201+
var cliWord = regexp.MustCompile(`(^|[^\w./~-])flashduty `)
202+
203+
// renameCLI replaces every command-word "flashduty" in s with name.
204+
func renameCLI(s, name string) string {
205+
return cliWord.ReplaceAllString(s, "${1}"+strings.ReplaceAll(name, "$", "$$")+" ")
168206
}
169207

170208
// newClient creates a go-flashduty client using the current factory.

‎internal/cli/root_test.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func TestRenameCLI(t *testing.T) {
10+
cases := []struct{ in, want string }{
11+
{"flashduty incident list", "fduty incident list"},
12+
{"run 'flashduty login' first", "run 'fduty login' first"},
13+
{"Run `flashduty incident timeline <id>`", "Run `fduty incident timeline <id>`"},
14+
{" flashduty team list\n flashduty team get 1", " fduty team list\n fduty team get 1"},
15+
{"cat rows.csv | flashduty enrichment upload", "cat rows.csv | fduty enrichment upload"},
16+
{"flashduty is managed by flashduty-runner", "fduty is managed by flashduty-runner"},
17+
// Not the command word: product name, paths, domains, identifiers.
18+
{"Flashduty CLI", "Flashduty CLI"},
19+
{"go-flashduty client", "go-flashduty client"},
20+
{"~/.flashduty config", "~/.flashduty config"},
21+
{"skills/flashduty cards", "skills/flashduty cards"},
22+
{"flashduty.example.com", "flashduty.example.com"},
23+
{"flashduty", "flashduty"},
24+
}
25+
for _, c := range cases {
26+
if got := renameCLI(c.in, "fduty"); got != c.want {
27+
t.Errorf("renameCLI(%q) = %q, want %q", c.in, got, c.want)
28+
}
29+
}
30+
// "$" in the name is literal text, not a regexp group reference.
31+
if got, want := renameCLI("run flashduty login", "fd$1"), "run fd$1 login"; got != want {
32+
t.Errorf("renameCLI with $ in name = %q, want %q", got, want)
33+
}
34+
}
35+
36+
func TestRenameCommandText(t *testing.T) {
37+
root := &cobra.Command{Use: "flashduty", Long: "Run 'flashduty login'."}
38+
child := &cobra.Command{Use: "list", Short: "List", Example: " flashduty team list"}
39+
child.Flags().String("id", "", "from 'flashduty team list'")
40+
root.PersistentFlags().String("x", "", "see 'flashduty login'")
41+
root.AddCommand(child)
42+
43+
renameCommandText(root, "fduty")
44+
45+
for _, c := range []struct{ got, want string }{
46+
{root.Long, "Run 'fduty login'."},
47+
{child.Example, " fduty team list"},
48+
{child.Flags().Lookup("id").Usage, "from 'fduty team list'"},
49+
{root.PersistentFlags().Lookup("x").Usage, "see 'fduty login'"},
50+
} {
51+
if c.got != c.want {
52+
t.Errorf("got %q, want %q", c.got, c.want)
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)