Skip to content

Commit aff8c9c

Browse files
committed
INF-1307 rescue cmd/twoctl/ + add shell completion guidance
The .gitignore had an unanchored `twoctl` line that matched both the binary AND the cmd/twoctl/ source dir, so the initial scaffold + two follow-up commits silently shipped without any of the CLI code. The repo on GitHub built only because no test referenced it - go test on internal packages still passed. This commit: - anchors .gitignore (/twoctl) so the dir is no longer matched - restores every cmd/twoctl/ source file (auth, root, operations, catalog, upgrade, errors, main, tests, embedded specs) - registers shell completions for enum-valued parameters via cobra's RegisterFlagCompletionFunc - tab-completion now suggests valid values for country codes, currencies, statuses etc. - calls MarkFlagRequired on required parameters so cobra rejects missing inputs with a clear usage error before the RunE fires - documents shell-completion setup for bash, zsh, fish, powershell
1 parent 48094a2 commit aff8c9c

16 files changed

Lines changed: 14283 additions & 1 deletion

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/dist/
22
/bin/
3-
twoctl
3+
/twoctl
44
*.test
55
*.out
66
.DS_Store

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,68 @@ Download from [releases](https://github.com/two-inc/twoctl/releases) and place o
2020
go install github.com/two-inc/twoctl/cmd/twoctl@latest
2121
```
2222

23+
## Shell completion
24+
25+
`twoctl` ships completions for bash, zsh, fish, and PowerShell. The binary
26+
generates them on demand via `twoctl completion <shell>`.
27+
28+
### bash
29+
30+
Linux:
31+
32+
```sh
33+
twoctl completion bash | sudo tee /etc/bash_completion.d/twoctl > /dev/null
34+
```
35+
36+
macOS (with `bash-completion@2` from Homebrew):
37+
38+
```sh
39+
twoctl completion bash > "$(brew --prefix)/etc/bash_completion.d/twoctl"
40+
```
41+
42+
If you don't have write access to a system path, source the completion in your
43+
`~/.bashrc`:
44+
45+
```sh
46+
echo 'source <(twoctl completion bash)' >> ~/.bashrc
47+
```
48+
49+
### zsh
50+
51+
```sh
52+
# one-shot (re-run on shell start):
53+
echo 'source <(twoctl completion zsh)' >> ~/.zshrc
54+
55+
# or persist to fpath:
56+
twoctl completion zsh > "${fpath[1]}/_twoctl"
57+
```
58+
59+
If `compinit` hasn't been initialised in your `~/.zshrc`, add:
60+
61+
```sh
62+
autoload -Uz compinit && compinit
63+
```
64+
65+
before the completion source line.
66+
67+
### fish
68+
69+
```sh
70+
twoctl completion fish | source # current shell
71+
twoctl completion fish > ~/.config/fish/completions/twoctl.fish # persistent
72+
```
73+
74+
### PowerShell
75+
76+
```powershell
77+
twoctl completion powershell | Out-String | Invoke-Expression # current session
78+
twoctl completion powershell >> $PROFILE # persistent
79+
```
80+
81+
After installing, open a new shell. Tab-completion works on subcommands,
82+
flags, and any flag value that has a known enum in the OpenAPI spec
83+
(country codes, currencies, statuses).
84+
2385
## Authenticate
2486

2587
`twoctl` reads your API key from (in order):

cmd/twoctl/cli/auth.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package cli
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"golang.org/x/term"
11+
12+
"github.com/two-inc/twoctl/internal/config"
13+
)
14+
15+
func init() {
16+
authCmd := &cobra.Command{
17+
Use: "auth",
18+
Short: "Manage API key authentication",
19+
}
20+
authCmd.AddCommand(
21+
authLoginCmd(),
22+
authLogoutCmd(),
23+
authWhoamiCmd(),
24+
)
25+
register(authCmd)
26+
}
27+
28+
func authLoginCmd() *cobra.Command {
29+
var keyFlag string
30+
cmd := &cobra.Command{
31+
Use: "login",
32+
Short: "Store an API key in the OS keychain",
33+
Long: `Store a Two API key in the OS keychain (macOS Keychain, GNOME Keyring,
34+
Windows Credential Manager). The key is read from --key, or prompted for
35+
interactively if not provided.
36+
37+
twoctl prefers the keychain over the TWO_API_KEY environment variable when
38+
both are set.`,
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
key := strings.TrimSpace(keyFlag)
41+
if key == "" {
42+
k, err := promptAPIKey(cmd)
43+
if err != nil {
44+
return err
45+
}
46+
key = k
47+
}
48+
if !looksLikeAPIKey(key) {
49+
return fmt.Errorf("that does not look like a Two API key (expected prefix secret_test_ or secret_prod_)")
50+
}
51+
if err := config.StoreKey(key); err != nil {
52+
return fmt.Errorf("storing key in keychain: %w", err)
53+
}
54+
fmt.Fprintln(cmd.OutOrStdout(), "stored API key in OS keychain")
55+
return nil
56+
},
57+
}
58+
cmd.Flags().StringVar(&keyFlag, "key", "", "API key to store (will prompt if omitted)")
59+
return cmd
60+
}
61+
62+
func authLogoutCmd() *cobra.Command {
63+
return &cobra.Command{
64+
Use: "logout",
65+
Short: "Remove the API key from the OS keychain",
66+
RunE: func(cmd *cobra.Command, args []string) error {
67+
if err := config.DeleteKey(); err != nil {
68+
return fmt.Errorf("removing key: %w", err)
69+
}
70+
fmt.Fprintln(cmd.OutOrStdout(), "removed cached API key")
71+
return nil
72+
},
73+
}
74+
}
75+
76+
func authWhoamiCmd() *cobra.Command {
77+
return &cobra.Command{
78+
Use: "whoami",
79+
Short: "Show which API key and environment will be used",
80+
RunE: func(cmd *cobra.Command, args []string) error {
81+
resolved, err := config.Resolve(flagAPIKey, flagEnv)
82+
if err != nil {
83+
return err
84+
}
85+
fmt.Fprintf(cmd.OutOrStdout(), "environment: %s (%s)\nkey source: %s\nkey prefix: %s\n",
86+
resolved.Env, resolved.Env.BaseURL(), resolved.Source, redact(resolved.APIKey))
87+
return nil
88+
},
89+
}
90+
}
91+
92+
func promptAPIKey(cmd *cobra.Command) (string, error) {
93+
fmt.Fprint(cmd.OutOrStdout(), "Two API key: ")
94+
if term.IsTerminal(int(os.Stdin.Fd())) {
95+
b, err := term.ReadPassword(int(os.Stdin.Fd()))
96+
fmt.Fprintln(cmd.OutOrStdout())
97+
if err != nil {
98+
return "", err
99+
}
100+
return strings.TrimSpace(string(b)), nil
101+
}
102+
r := bufio.NewReader(os.Stdin)
103+
line, err := r.ReadString('\n')
104+
if err != nil {
105+
return "", err
106+
}
107+
return strings.TrimSpace(line), nil
108+
}
109+
110+
func looksLikeAPIKey(s string) bool {
111+
return strings.HasPrefix(s, "secret_test_") ||
112+
strings.HasPrefix(s, "secret_prod_") ||
113+
strings.HasPrefix(s, "secret_live_") ||
114+
strings.HasPrefix(s, "secret_sandbox_")
115+
}
116+
117+
// redact returns a key with everything between the env prefix and the last
118+
// four characters replaced by asterisks. Used for whoami output only.
119+
func redact(key string) string {
120+
if len(key) < 16 {
121+
return "****"
122+
}
123+
prefix := key
124+
tail := key[len(key)-4:]
125+
if idx := strings.Index(key, "_"); idx >= 0 {
126+
if idx2 := strings.Index(key[idx+1:], "_"); idx2 >= 0 {
127+
prefix = key[:idx+1+idx2+1]
128+
}
129+
}
130+
return prefix + "****" + tail
131+
}

cmd/twoctl/cli/catalog.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"sort"
6+
7+
"github.com/getkin/kin-openapi/openapi3"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func init() {
12+
cmd := &cobra.Command{
13+
Use: "catalog",
14+
Short: "Print the full operation catalog as JSON for agent discovery",
15+
Long: `Emit a machine-readable catalog of every operation twoctl knows about.
16+
17+
Each entry includes the CLI command path, HTTP method, URL path template,
18+
summary, and the list of flags with their kind (path/query/header/body),
19+
type, and required status. Designed to be piped to an LLM or stored alongside
20+
agent prompts so the agent can plan calls without parsing --help text.`,
21+
RunE: runCatalog,
22+
}
23+
cmd.Flags().StringP("api", "a", "", "limit catalog to a single API (checkout, billing-account, repay, recourse, company, limits)")
24+
register(cmd)
25+
}
26+
27+
type catalogFlag struct {
28+
Name string `json:"name"`
29+
In string `json:"in"`
30+
Type string `json:"type"`
31+
Required bool `json:"required"`
32+
Desc string `json:"description,omitempty"`
33+
}
34+
35+
type catalogOp struct {
36+
Command string `json:"command"` // e.g. "twoctl checkout create-order"
37+
API string `json:"api"` // "checkout"
38+
OperationID string `json:"operation_id,omitempty"`
39+
Method string `json:"method"`
40+
Path string `json:"path"`
41+
Summary string `json:"summary,omitempty"`
42+
Flags []catalogFlag `json:"flags,omitempty"`
43+
HasBody bool `json:"has_body"`
44+
BodyRequired bool `json:"body_required,omitempty"`
45+
}
46+
47+
func runCatalog(cmd *cobra.Command, args []string) error {
48+
wantAPI, _ := cmd.Flags().GetString("api")
49+
var out []catalogOp
50+
51+
for _, m := range apiMeta {
52+
if wantAPI != "" && wantAPI != m.use {
53+
continue
54+
}
55+
raw, err := specFS.ReadFile("specs/" + m.file)
56+
if err != nil {
57+
return err
58+
}
59+
doc, err := openapi3.NewLoader().LoadFromData(raw)
60+
if err != nil {
61+
return err
62+
}
63+
for path, item := range doc.Paths.Map() {
64+
for method, op := range item.Operations() {
65+
if op == nil {
66+
continue
67+
}
68+
name := commandNameFor(op, method, path)
69+
for _, s := range stripSuffixes {
70+
if len(name) > len(s) && name[len(name)-len(s):] == s {
71+
name = name[:len(name)-len(s)]
72+
break
73+
}
74+
}
75+
entry := catalogOp{
76+
Command: "twoctl " + m.use + " " + name,
77+
API: m.use,
78+
OperationID: op.OperationID,
79+
Method: method,
80+
Path: path,
81+
Summary: op.Summary,
82+
}
83+
for _, ref := range op.Parameters {
84+
p := ref.Value
85+
if p == nil {
86+
continue
87+
}
88+
t := "string"
89+
if p.Schema != nil && p.Schema.Value != nil && p.Schema.Value.Type != nil {
90+
if s := p.Schema.Value.Type.Slice(); len(s) > 0 {
91+
t = s[0]
92+
}
93+
}
94+
entry.Flags = append(entry.Flags, catalogFlag{
95+
Name: toKebab(p.Name),
96+
In: p.In,
97+
Type: t,
98+
Required: p.Required,
99+
Desc: p.Description,
100+
})
101+
}
102+
if op.RequestBody != nil && op.RequestBody.Value != nil {
103+
entry.HasBody = true
104+
entry.BodyRequired = op.RequestBody.Value.Required
105+
}
106+
out = append(out, entry)
107+
}
108+
}
109+
}
110+
sort.Slice(out, func(i, j int) bool { return out[i].Command < out[j].Command })
111+
enc := json.NewEncoder(cmd.OutOrStdout())
112+
enc.SetIndent("", " ")
113+
return enc.Encode(out)
114+
}

0 commit comments

Comments
 (0)