Skip to content

Commit 9e499e5

Browse files
committed
feat: add editor connect registry
1 parent 8204986 commit 9e499e5

5 files changed

Lines changed: 533 additions & 4 deletions

File tree

cmd/collect/README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,16 @@ stint cli install
3636
# 3. Optional: write collector-specific defaults to ~/.stint/collect.json
3737
stint collect config init
3838

39-
# 4. Dry-run to see what would be sent (no POST, no API key needed)
39+
# 4. Configure detected editors to send WakaTime-compatible heartbeats to Stint
40+
stint connect
41+
42+
# 5. Optional: install supported editor extensions too
43+
stint connect --deep
44+
45+
# 6. Dry-run to see what would be sent (no POST, no API key needed)
4046
stint collect --dry-run
4147

42-
# 5. Real run
48+
# 7. Real run
4349
stint collect
4450

4551
# Inspect the effective config (api_key is redacted)
@@ -56,6 +62,13 @@ plugin calls, credentials are resolved as: explicit flags, `STINT_*` env,
5662
`~/.stint.cfg`, `~/.wakatime.cfg`, then built-in defaults. `WAKATIME_API_KEY`
5763
is accepted as a later API-key fallback for compatibility.
5864

65+
`stint connect` detects Tier-1 editors and repairs the shared
66+
`~/.wakatime.cfg` bridge they already understand. Current registry rows cover
67+
VS Code, Cursor, Windsurf, VSCodium, JetBrains IDEs, Vim, Neovim, and Zed.
68+
`--deep` additionally asks VS Code-family launchers to install
69+
`WakaTime.vscode-wakatime` and JetBrains launchers to install
70+
`com.wakatime.intellij.plugin`.
71+
5972
## Configuration
6073

6174
Collector settings are resolved from these layers, **highest precedence first**:

internal/stintcli/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ func diagnosticOptions(args []string) (Options, error) {
191191

192192
func diagnosticCommand(command string) bool {
193193
switch command {
194-
case "heartbeat", "heartbeats", "setup", "cli", "today", "today-goal", "file-experts", "stats", "projects",
194+
case "heartbeat", "heartbeats", "setup", "connect", "cli", "today", "today-goal", "file-experts", "stats", "projects",
195195
"goals", "account", "me", "health", "dev", "meta", "api-docs", "openapi", "leaders",
196196
"editors", "program-languages", "program_languages", "users", "share", "all-time",
197197
"all-time-since-today", "machine-names", "machine_names", "user-agents", "user_agents",

internal/stintcli/cobra.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ func newCobraRoot(stdin io.Reader, stdout, stderr io.Writer) *cobra.Command {
4646
addCollectHelpFlags(collect)
4747
root.AddCommand(collect)
4848

49+
connect := &cobra.Command{
50+
Use: "connect",
51+
Short: "Detect and configure installed editors",
52+
RunE: withHelp(func(args []string) error {
53+
return runConnect(args, stdout)
54+
}),
55+
DisableFlagParsing: true,
56+
}
57+
addConnectHelpFlags(connect)
58+
root.AddCommand(connect)
59+
4960
cli := &cobra.Command{Use: "cli", Short: "Manage companion CLIs"}
5061
install := &cobra.Command{
5162
Use: "install",
@@ -62,7 +73,7 @@ func newCobraRoot(stdin io.Reader, stdout, stderr io.Writer) *cobra.Command {
6273

6374
func cobraManagedCommand(command string) bool {
6475
switch command {
65-
case "setup", "cli", "collect":
76+
case "setup", "connect", "cli", "collect":
6677
return true
6778
default:
6879
return false
@@ -110,3 +121,10 @@ func addCollectHelpFlags(cmd *cobra.Command) {
110121
flags.Bool("print-config", false, "print the resolved config and exit")
111122
flags.Bool("init-config", false, "write a starter config if absent, then exit")
112123
}
124+
125+
func addConnectHelpFlags(cmd *cobra.Command) {
126+
flags := cmd.Flags()
127+
flags.Bool("deep", false, "install editor extensions when supported")
128+
flags.String("server", "", "Stint API URL")
129+
flags.String("key", "", "Stint API key")
130+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package stintcli
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"sort"
10+
"strings"
11+
)
12+
13+
type EditorSpec struct {
14+
ID string
15+
Name string
16+
Binaries []string
17+
ConfigPaths []string
18+
DeepInstall func(EditorSpec) error
19+
}
20+
21+
type EditorEntry struct {
22+
Spec EditorSpec
23+
}
24+
25+
type EditorRegistry map[string]EditorEntry
26+
27+
var (
28+
editorLookPath = exec.LookPath
29+
editorRunCommand = func(name string, args ...string) error {
30+
cmd := exec.Command(name, args...) //nolint:gosec // Editor command is selected from a fixed registry row.
31+
var stderr bytes.Buffer
32+
cmd.Stderr = &stderr
33+
if err := cmd.Run(); err != nil {
34+
if message := strings.TrimSpace(stderr.String()); message != "" {
35+
return fmt.Errorf("%s: %w", message, err)
36+
}
37+
return err
38+
}
39+
return nil
40+
}
41+
)
42+
43+
func DefaultEditorRegistry() EditorRegistry {
44+
r := EditorRegistry{}
45+
r.register(EditorSpec{ID: "vscode", Name: "VS Code", Binaries: []string{"code"}, DeepInstall: installVSCodeWakaTime})
46+
r.register(EditorSpec{ID: "cursor", Name: "Cursor", Binaries: []string{"cursor"}, DeepInstall: installVSCodeWakaTime})
47+
r.register(EditorSpec{ID: "windsurf", Name: "Windsurf", Binaries: []string{"windsurf"}, DeepInstall: installVSCodeWakaTime})
48+
r.register(EditorSpec{ID: "vscodium", Name: "VSCodium", Binaries: []string{"codium"}, DeepInstall: installVSCodeWakaTime})
49+
r.register(EditorSpec{
50+
ID: "jetbrains",
51+
Name: "JetBrains IDEs",
52+
Binaries: []string{"idea", "webstorm", "goland", "pycharm", "rubymine", "clion", "phpstorm", "rider", "datagrip", "android-studio", "studio"},
53+
ConfigPaths: []string{
54+
"~/.config/JetBrains",
55+
"~/Library/Application Support/JetBrains",
56+
"~/AppData/Roaming/JetBrains",
57+
},
58+
DeepInstall: installJetBrainsWakaTime,
59+
})
60+
r.register(EditorSpec{ID: "vim", Name: "Vim", Binaries: []string{"vim"}})
61+
r.register(EditorSpec{ID: "neovim", Name: "Neovim", Binaries: []string{"nvim"}})
62+
r.register(EditorSpec{ID: "zed", Name: "Zed", Binaries: []string{"zed"}})
63+
return r
64+
}
65+
66+
func (r EditorRegistry) register(spec EditorSpec) {
67+
r[spec.ID] = EditorEntry{Spec: spec}
68+
}
69+
70+
func (r EditorRegistry) IDs() []string {
71+
ids := make([]string, 0, len(r))
72+
for id := range r {
73+
ids = append(ids, id)
74+
}
75+
sort.Strings(ids)
76+
return ids
77+
}
78+
79+
func (r EditorRegistry) DetectInstalled() []string {
80+
var ids []string
81+
for _, id := range r.IDs() {
82+
if r[id].Detected() {
83+
ids = append(ids, id)
84+
}
85+
}
86+
return ids
87+
}
88+
89+
func (e EditorEntry) Detected() bool {
90+
for _, binary := range e.Spec.Binaries {
91+
if _, err := editorLookPath(binary); err == nil {
92+
return true
93+
}
94+
}
95+
for _, path := range e.Spec.ConfigPaths {
96+
if info, err := os.Stat(expandHome(path)); err == nil && info.IsDir() {
97+
return true
98+
}
99+
}
100+
return false
101+
}
102+
103+
func runConnect(args []string, stdout io.Writer) error {
104+
fs := newFlagSet("stint connect")
105+
deep := fs.Bool("deep", false, "install editor extensions when supported")
106+
server := fs.String("server", "", "Stint API URL")
107+
key := fs.String("key", "", "Stint API key")
108+
if err := fs.Parse(args); err != nil {
109+
return err
110+
}
111+
apiURL, apiKey, err := connectCredentials(*server, *key)
112+
if err != nil {
113+
return err
114+
}
115+
reg := DefaultEditorRegistry()
116+
ids := reg.DetectInstalled()
117+
if len(ids) == 0 {
118+
fmt.Fprintln(stdout, "no supported editors detected")
119+
return nil
120+
}
121+
if err := writeSetupConfig(DefaultWakaTimeConfigPath(), apiURL, apiKey, false); err != nil {
122+
return err
123+
}
124+
var failures []string
125+
for _, id := range ids {
126+
entry := reg[id]
127+
status := "configured"
128+
if *deep && entry.Spec.DeepInstall != nil {
129+
if err := entry.Spec.DeepInstall(entry.Spec); err != nil {
130+
status = "configured; extension install failed"
131+
failures = append(failures, fmt.Sprintf("%s: %v", id, err))
132+
} else {
133+
status = "configured; extension installed"
134+
}
135+
}
136+
fmt.Fprintf(stdout, "%s %s\n", id, status)
137+
}
138+
if len(failures) > 0 {
139+
return fmt.Errorf("deep install failures: %s", strings.Join(failures, "; "))
140+
}
141+
return nil
142+
}
143+
144+
func connectCredentials(server, key string) (string, string, error) {
145+
nativeCfg, err := loadNativeConfig()
146+
if err != nil {
147+
return "", "", err
148+
}
149+
wakaCfg, err := LoadConfig(DefaultWakaTimeConfigPath())
150+
if err != nil {
151+
return "", "", err
152+
}
153+
apiURL := first(server, os.Getenv("STINT_API_URL"), configFirst(nativeCfg, "api_url", "api-url", "apiurl"), configFirst(wakaCfg, "api_url", "api-url", "apiurl"))
154+
apiKey := first(key, os.Getenv("STINT_API_KEY"))
155+
if apiKey == "" {
156+
apiKey, err = resolveAPIKeyFromConfigs("", []Config{nativeCfg, wakaCfg}, os.Getenv("WAKATIME_API_KEY"))
157+
if err != nil {
158+
return "", "", err
159+
}
160+
}
161+
if apiURL == "" || apiKey == "" {
162+
return "", "", fmt.Errorf("missing Stint credentials; run `stint setup` first or pass --server and --key")
163+
}
164+
return apiURL, apiKey, nil
165+
}
166+
167+
func installVSCodeWakaTime(spec EditorSpec) error {
168+
for _, binary := range spec.Binaries {
169+
path, err := editorLookPath(binary)
170+
if err != nil {
171+
continue
172+
}
173+
return editorRunCommand(path, "--install-extension", "WakaTime.vscode-wakatime")
174+
}
175+
return fmt.Errorf("editor command not found")
176+
}
177+
178+
func installJetBrainsWakaTime(spec EditorSpec) error {
179+
var paths []string
180+
for _, binary := range spec.Binaries {
181+
path, err := editorLookPath(binary)
182+
if err != nil {
183+
continue
184+
}
185+
paths = append(paths, path)
186+
}
187+
if len(paths) == 0 {
188+
return fmt.Errorf("JetBrains command-line launcher not found")
189+
}
190+
var failures []string
191+
successes := 0
192+
for _, path := range paths {
193+
if err := editorRunCommand(path, "installPlugins", "com.wakatime.intellij.plugin"); err != nil {
194+
failures = append(failures, fmt.Sprintf("%s: %v", path, err))
195+
continue
196+
}
197+
successes++
198+
}
199+
if successes > 0 {
200+
return nil
201+
}
202+
return fmt.Errorf("all JetBrains plugin installs failed: %s", strings.Join(failures, "; "))
203+
}
204+
205+
func containsString(values []string, want string) bool {
206+
for _, value := range values {
207+
if value == want {
208+
return true
209+
}
210+
}
211+
return false
212+
}

0 commit comments

Comments
 (0)