internal/cli/bootstrap.go
1
package cli
3
import (
4
"bufio"
5
"errors"
6
"fmt"
7
"io"
8
"io/fs"
9
"os"
10
"strings"
12
"github.com/koment-dev/koment/internal/agentpolicy"
13
"github.com/koment-dev/koment/internal/policy"
14
)
16
func runBootstrap(args []string, env Environment) int {
17
flags := flagSet("bootstrap", env)
18
agentsList := flags.String("agents", "", "comma-separated adapter names to install (claude, copilot, cursor, codex, opencode, agents)")
19
all := flags.Bool("all", false, "install every supported adapter")
20
policyOnly := flags.Bool("policy-only", false, "install the policy only; do not refresh any adapter")
21
nonInteractive := flags.Bool("non-interactive", false, "do not prompt; require explicit --agents, --all or --policy-only")
22
if code, ok := parse(flags, args); !ok {
23
return code
24
}
25
if flags.NArg() != 0 {
26
return misuse(env, "bootstrap takes no arguments")
27
}
29
annotations, err := openStore()
30
if err != nil {
31
return fail(env, err)
32
}
33
existing, err := policy.Load(annotations.Root())
34
if err != nil && !isMissingPolicy(err) {
35
return fail(env, err)
36
}
37
havePolicy := err == nil
39
selected, err := resolveBootstrapAdapters(*agentsList, *all, *policyOnly, *nonInteractive, existing, env)
40
if err != nil {
41
return misuse(env, "%v", err)
42
}
44
configured := existing
45
if !havePolicy {
46
configured = policy.Default()
47
}
48
if len(selected) > 0 {
49
configured.Spec.Agents.Adapters = selected
50
}
52
if !havePolicy {
53
if err := policy.Save(annotations.Root(), configured); err != nil {
54
return fail(env, err)
55
}
56
fmt.Fprintf(env.Stdout, "installed %s\n", policy.FileName)
57
}
59
var changes []agentpolicy.Change
60
if *policyOnly {
61
changes, err = agentpolicy.InstallContributing(annotations.Root(), configured)
62
} else {
63
changes, err = agentpolicy.Install(annotations.Root(), configured)
64
}
65
if err != nil {
66
return fail(env, err)
67
}
68
for _, change := range changes {
69
fmt.Fprintf(env.Stdout, "%s %s\n", change.Action, change.Path)
70
}
71
if len(changes) == 0 {
72
fmt.Fprintln(env.Stdout, "agent policy: already current")
73
}
74
return ExitOK
75
}
77
func resolveBootstrapAdapters(agentsList string, all, policyOnly, nonInteractive bool, existing policy.Policy, env Environment) ([]policy.Adapter, error) {
78
switch {
79
case agentsList != "":
80
return parseAdapterList(agentsList)
81
case all:
82
return append([]policy.Adapter(nil), policy.Default().Spec.Agents.Adapters...), nil
83
case policyOnly:
84
return nil, nil
85
case existing.Spec.Agents.Adapters != nil:
86
return append([]policy.Adapter(nil), existing.Spec.Agents.Adapters...), nil
87
}
88
if nonInteractive || !stdinIsTerminal() {
89
return nil, fmt.Errorf("no adapter selection resolved; pass --agents <list>, --all, or --policy-only (or run interactively in a terminal)")
90
}
91
return promptAdapters(existing.Spec.Agents.Adapters, env)
92
}
94
func parseAdapterList(raw string) ([]policy.Adapter, error) {
95
parts := strings.Split(raw, ",")
96
selected := make([]policy.Adapter, 0, len(parts))
97
seen := map[policy.Adapter]bool{}
98
for _, part := range parts {
99
name := strings.TrimSpace(part)
100
if name == "" {
101
continue
102
}
103
adapter := policy.Adapter(name)
104
if !validAdapter(adapter) {
105
return nil, fmt.Errorf("unknown adapter %q", name)
106
}
107
if seen[adapter] {
108
continue
109
}
110
seen[adapter] = true
111
selected = append(selected, adapter)
112
}
113
return selected, nil
114
}
116
func validAdapter(adapter policy.Adapter) bool {
117
for _, candidate := range policy.Default().Spec.Agents.Adapters {
118
if candidate == adapter {
119
return true
120
}
121
}
122
return false
123
}
125
func promptAdapters(current []policy.Adapter, env Environment) ([]policy.Adapter, error) {
126
all := policy.Default().Spec.Agents.Adapters
127
selected := make([]policy.Adapter, 0, len(all))
128
for _, adapter := range all {
129
defaultYes := containsAdapter(current, adapter)
130
answer, err := askYesNo(adapterName(adapter), defaultYes, env)
131
if err != nil {
132
return nil, err
133
}
134
if answer {
135
selected = append(selected, adapter)
136
}
137
}
138
return selected, nil
139
}
141
func askYesNo(prompt string, defaultYes bool, env Environment) (bool, error) {
142
hint := "[Y/n]"
143
if !defaultYes {
144
hint = "[y/N]"
145
}
146
fmt.Fprintf(env.Stdout, "%s %s ", prompt, hint)
147
reader := bufio.NewReader(env.Stdin)
148
line, err := reader.ReadString('\n')
149
if err != nil && err != io.EOF {
150
return false, fmt.Errorf("reading answer: %w", err)
151
}
152
answer := strings.TrimSpace(line)
153
switch strings.ToLower(answer) {
154
case "":
155
return defaultYes, nil
156
case "y", "yes":
157
return true, nil
158
case "n", "no":
159
return false, nil
160
}
161
return defaultYes, nil
162
}
164
func containsAdapter(list []policy.Adapter, adapter policy.Adapter) bool {
165
for _, candidate := range list {
166
if candidate == adapter {
167
return true
168
}
169
}
170
return false
171
}
173
func adapterName(adapter policy.Adapter) string {
174
switch adapter {
175
case policy.AdapterAgents:
176
return "AGENTS.md (managed contract for every client)"
177
case policy.AdapterClaude:
178
return "Claude Code (.mcp.json + CLAUDE.md import)"
179
case policy.AdapterCopilot:
180
return "GitHub Copilot (.github/copilot-instructions.md + .vscode/mcp.json)"
181
case policy.AdapterCursor:
182
return "Cursor (.cursor/rules/koment.mdc + .cursor/mcp.json)"
183
case policy.AdapterCodex:
184
return "Codex CLI (.codex/hooks.json + .codex/config.toml)"
185
case policy.AdapterOpencode:
186
return "opencode (.opencode/plugins/koment.js + opencode.json)"
187
}
188
return string(adapter)
189
}
191
func stdinIsTerminal() bool {
192
info, err := os.Stdin.Stat()
193
if err != nil {
194
return false
195
}
196
return info.Mode()&os.ModeCharDevice != 0
197
}
199
func isMissingPolicy(err error) bool {
200
return errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist)
201
}