webctx 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +97 -0
  2. package/CONTRIBUTORS.md +126 -0
  3. package/LICENSE +202 -0
  4. package/Makefile +69 -0
  5. package/README.md +121 -0
  6. package/bin/webctx.js +28 -0
  7. package/cmd/webctx/main.go +11 -0
  8. package/docs/porting-status.md +173 -0
  9. package/go.mod +3 -0
  10. package/internal/app/app.go +139 -0
  11. package/internal/app/app_test.go +194 -0
  12. package/internal/app/github.go +2084 -0
  13. package/internal/app/github_acceptance_test.go +166 -0
  14. package/internal/app/github_actions.go +1008 -0
  15. package/internal/app/github_actions_test.go +484 -0
  16. package/internal/app/github_activity_deployments.go +503 -0
  17. package/internal/app/github_activity_deployments_test.go +321 -0
  18. package/internal/app/github_commits.go +1004 -0
  19. package/internal/app/github_commits_test.go +503 -0
  20. package/internal/app/github_discussions_gists.go +1156 -0
  21. package/internal/app/github_discussions_gists_test.go +425 -0
  22. package/internal/app/github_issues.go +1616 -0
  23. package/internal/app/github_issues_test.go +731 -0
  24. package/internal/app/github_packages_projects.go +429 -0
  25. package/internal/app/github_packages_projects_test.go +168 -0
  26. package/internal/app/github_phase9_overview_stress_test.go +178 -0
  27. package/internal/app/github_pull_views.go +1141 -0
  28. package/internal/app/github_pull_views_test.go +603 -0
  29. package/internal/app/github_pulls.go +1459 -0
  30. package/internal/app/github_pulls_test.go +715 -0
  31. package/internal/app/github_refs.go +621 -0
  32. package/internal/app/github_refs_test.go +260 -0
  33. package/internal/app/github_search_profiles.go +505 -0
  34. package/internal/app/github_search_profiles_test.go +352 -0
  35. package/internal/app/github_test.go +888 -0
  36. package/internal/app/scrape.go +264 -0
  37. package/internal/app/tools.go +670 -0
  38. package/internal/buildinfo/buildinfo.go +16 -0
  39. package/package.json +66 -0
  40. package/scripts/postinstall.js +137 -0
@@ -0,0 +1,173 @@
1
+ # webctx TypeScript -> Go porting status
2
+
3
+ This document is the handoff reference for future agents working on `amxv/webctx`.
4
+
5
+ ## Goal
6
+
7
+ Port the CLI behavior from `amxv/webctx-ts` into pure Go while keeping the command-line interface and provider behavior effectively one-to-one for the CLI use case.
8
+
9
+ Scope intentionally excludes the MCP/server/dashboard pieces from the TypeScript repo.
10
+
11
+ ## Source areas reviewed in `webctx-ts`
12
+
13
+ - `cli.ts`
14
+ - `tools/search.ts`
15
+ - `tools/read-link.ts`
16
+ - `tools/map-site.ts`
17
+ - `lib/search/brave.ts`
18
+ - `lib/search/tavily.ts`
19
+ - `lib/search/exa.ts`
20
+ - `lib/ranking.ts`
21
+ - `lib/utils.ts`
22
+ - `lib/scraping.ts`
23
+ - `lib/firecrawl-queue.ts`
24
+ - `lib/rate-limiter.ts`
25
+
26
+ ## Completed
27
+
28
+ ### CLI surface
29
+
30
+ Implemented in Go:
31
+
32
+ - `webctx search <query> [--exclude domain1,domain2] [--keyword phrase]`
33
+ - `webctx read-link <url>`
34
+ - `webctx map-site <url>`
35
+ - `webctx --help`
36
+ - `webctx --version`
37
+
38
+ Notes:
39
+
40
+ - `--version` now prints the bare version string, matching the TypeScript CLI.
41
+ - Error handling is exit-code based in Go rather than promise rejection based.
42
+
43
+ ### Search port
44
+
45
+ Implemented:
46
+
47
+ - Brave HTTP client
48
+ - Tavily HTTP client using direct HTTP API instead of the TypeScript SDK
49
+ - Exa HTTP client
50
+ - provider fan-out with per-provider timeout
51
+ - duplicate-aware reranking
52
+ - excluded-domain filtering
53
+ - HTML entity decoding
54
+ - keyword truncation to 5 words for Exa include-text mode
55
+ - top 35 result output limit
56
+
57
+ Current behavior matches the TypeScript CLI design:
58
+
59
+ - normal search mode queries Brave + Tavily + Exa
60
+ - keyword mode queries Exa only
61
+ - user/domain exclusions are applied after provider collection, matching the TypeScript tool flow
62
+
63
+ ### Read-link port
64
+
65
+ Implemented:
66
+
67
+ - GitHub raw-content fast path
68
+ - `.md` fast path with HEAD probe
69
+ - Firecrawl scrape fallback with the same agent-oriented request settings
70
+ - PDF parser enablement for `.pdf` URLs
71
+
72
+ Kept settings aligned with the TypeScript CLI:
73
+
74
+ - `formats: ["markdown"]`
75
+ - `onlyMainContent: true`
76
+ - `skipTlsVerification: true`
77
+ - `blockAds: true`
78
+ - `removeBase64Images: true`
79
+ - `maxAge: 600000`
80
+ - same excluded tags list
81
+
82
+ ### Map-site port
83
+
84
+ Implemented with the same Firecrawl map settings:
85
+
86
+ - `sitemap: "include"`
87
+ - `includeSubdomains: true`
88
+ - `ignoreQueryParameters: true`
89
+ - `limit: 5000`
90
+
91
+ ### Firecrawl queue / rate limiting
92
+
93
+ Implemented in Go:
94
+
95
+ - singleton-style queue wrapper
96
+ - token bucket limiter at 10 requests/minute
97
+ - serialized queue processing for Firecrawl operations
98
+
99
+ This is not a literal line-by-line port, but preserves the same operational intent.
100
+
101
+ ### Release/publish setup
102
+
103
+ Updated to be release-ready for the real `webctx` CLI:
104
+
105
+ - GitHub Actions workflow now builds `webctx` instead of the old template placeholder
106
+ - release binaries embed the tagged version into `internal/buildinfo.Version`
107
+ - npm metadata now describes the actual CLI instead of the template
108
+ - README / agent / maintainer docs updated for the real repo
109
+
110
+ ## Intentionally not ported
111
+
112
+ - MCP/server behavior
113
+ - Next.js app/dashboard code
114
+ - database/logging layers unrelated to the CLI
115
+
116
+ These can be added later only if explicitly requested.
117
+
118
+ ## Current repo files of interest
119
+
120
+ - `cmd/webctx/main.go`
121
+ - `internal/app/app.go`
122
+ - `internal/app/tools.go`
123
+ - `internal/app/scrape.go`
124
+ - `internal/app/app_test.go`
125
+ - `.github/workflows/release.yml`
126
+ - `scripts/postinstall.js`
127
+ - `README.md`
128
+
129
+ ## Verification already completed
130
+
131
+ - `go test ./...`
132
+ - `go build ./cmd/webctx`
133
+
134
+ ## Live validation notes
135
+
136
+ Live CLI validation was run against a real `.env.local` on the Sprite machine.
137
+
138
+ Confirmed working live:
139
+
140
+ - combined `search` path returns real web results
141
+ - public GitHub blob `read-link` fast path works
142
+ - Firecrawl-backed `read-link` works
143
+ - Firecrawl-backed `map-site` works
144
+
145
+ Observed external/provider constraints during live validation:
146
+
147
+ - `search --keyword` currently depends on Exa-only results and could not be fully validated because the live Exa account returned `NO_MORE_CREDITS`
148
+ - private GitHub blob URLs are not readable via unauthenticated raw-content fetch, so they fall through to the general scrape path
149
+
150
+ These findings were from live provider behavior, not from compile/test failures in the Go port.
151
+
152
+ ## Good next checks for future agents
153
+
154
+ 1. Run live end-to-end checks against real provider keys for:
155
+ - normal multi-provider search
156
+ - Exa keyword-only search mode
157
+ - GitHub raw-content read-link
158
+ - `.md` fast path read-link
159
+ - Firecrawl scrape fallback
160
+ - Firecrawl map-site
161
+
162
+ 2. Compare a handful of live outputs from `webctx-ts` and Go `webctx` for formatting parity.
163
+
164
+ 3. If performance tuning is needed, focus on:
165
+ - HTTP client reuse
166
+ - provider timeout tuning
167
+ - Firecrawl queue behavior under concurrent use
168
+
169
+ ## Constraints to preserve
170
+
171
+ - Keep the CLI output simple and agent-friendly.
172
+ - Keep the Firecrawl request settings stable unless explicitly asked to change them.
173
+ - Keep the release asset naming contract stable unless postinstall/workflow are updated together.
package/go.mod ADDED
@@ -0,0 +1,3 @@
1
+ module github.com/amxv/webctx
2
+
3
+ go 1.26
@@ -0,0 +1,139 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "strings"
7
+
8
+ "github.com/amxv/webctx/internal/buildinfo"
9
+ )
10
+
11
+ const commandName = "webctx"
12
+
13
+ var version = buildinfo.CurrentVersion()
14
+
15
+ func Run(args []string, stdout, stderr io.Writer) int {
16
+ loadEnvLocal()
17
+
18
+ if len(args) == 0 || isHelpArg(args[0]) {
19
+ _, _ = fmt.Fprintln(stdout, usageText())
20
+ return 0
21
+ }
22
+
23
+ if args[0] == "--version" || args[0] == "-v" {
24
+ _, _ = fmt.Fprintln(stdout, version)
25
+ return 0
26
+ }
27
+
28
+ tool := args[0]
29
+ flags, positional := parseArgs(args[1:])
30
+ input := ""
31
+ if len(positional) > 0 {
32
+ input = positional[0]
33
+ }
34
+
35
+ switch tool {
36
+ case "search":
37
+ query := strings.Join(positional, " ")
38
+ if strings.TrimSpace(query) == "" {
39
+ _, _ = fmt.Fprintln(stderr, "Error: search requires a query")
40
+ _, _ = fmt.Fprintln(stdout, "Usage: webctx search <query> [--exclude domains] [--keyword phrase]")
41
+ return 1
42
+ }
43
+ excludeDomains := splitCSV(flags["exclude"])
44
+ text, err := Search(SearchParams{Query: query, ExcludeDomains: excludeDomains, IncludeKeyword: flags["keyword"]})
45
+ if err != nil {
46
+ _, _ = fmt.Fprintln(stderr, err.Error())
47
+ return 1
48
+ }
49
+ _, _ = fmt.Fprintln(stdout, text)
50
+ return 0
51
+ case "read-link":
52
+ if strings.TrimSpace(input) == "" {
53
+ _, _ = fmt.Fprintln(stderr, "Error: read-link requires a URL")
54
+ _, _ = fmt.Fprintln(stdout, "Usage: webctx read-link <url>")
55
+ return 1
56
+ }
57
+ text, err := ReadLink(input)
58
+ if err != nil {
59
+ _, _ = fmt.Fprintln(stderr, err.Error())
60
+ return 1
61
+ }
62
+ _, _ = fmt.Fprintln(stdout, text)
63
+ return 0
64
+ case "map-site":
65
+ if strings.TrimSpace(input) == "" {
66
+ _, _ = fmt.Fprintln(stderr, "Error: map-site requires a URL")
67
+ _, _ = fmt.Fprintln(stdout, "Usage: webctx map-site <url>")
68
+ return 1
69
+ }
70
+ text, err := MapSite(input)
71
+ if err != nil {
72
+ _, _ = fmt.Fprintln(stderr, err.Error())
73
+ return 1
74
+ }
75
+ _, _ = fmt.Fprintln(stdout, text)
76
+ return 0
77
+ default:
78
+ _, _ = fmt.Fprintln(stderr, "Unknown tool:", tool)
79
+ _, _ = fmt.Fprintln(stdout, usageText())
80
+ return 1
81
+ }
82
+ }
83
+
84
+ func usageText() string {
85
+ return fmt.Sprintf(`webctx v%s - Web search & browsing CLI
86
+
87
+ Usage:
88
+ webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
89
+ webctx read-link <url>
90
+ webctx map-site <url>
91
+
92
+ Examples:
93
+ webctx search "next.js server components"
94
+ webctx search "react hooks" --exclude youtube.com,vimeo.com
95
+ webctx search "drizzle orm" --keyword "migration guide"
96
+ webctx read-link https://docs.example.com/guide
97
+ webctx map-site https://example.com`, version)
98
+ }
99
+
100
+ func parseArgs(args []string) (map[string]string, []string) {
101
+ flags := map[string]string{}
102
+ positional := make([]string, 0, len(args))
103
+ for i := 0; i < len(args); i++ {
104
+ if strings.HasPrefix(args[i], "--") && i+1 < len(args) {
105
+ flags[strings.TrimPrefix(args[i], "--")] = args[i+1]
106
+ i++
107
+ continue
108
+ }
109
+ positional = append(positional, args[i])
110
+ }
111
+ return flags, positional
112
+ }
113
+
114
+ func splitCSV(v string) []string {
115
+ if strings.TrimSpace(v) == "" {
116
+ return nil
117
+ }
118
+ parts := strings.Split(v, ",")
119
+ out := make([]string, 0, len(parts))
120
+ for _, part := range parts {
121
+ trimmed := strings.TrimSpace(part)
122
+ if trimmed != "" {
123
+ out = append(out, trimmed)
124
+ }
125
+ }
126
+ if len(out) == 0 {
127
+ return nil
128
+ }
129
+ return out
130
+ }
131
+
132
+ func isHelpArg(v string) bool {
133
+ switch v {
134
+ case "-h", "--help", "help":
135
+ return true
136
+ default:
137
+ return false
138
+ }
139
+ }
@@ -0,0 +1,194 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "os"
6
+ "path/filepath"
7
+ "runtime"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestRunRootHelp(t *testing.T) {
13
+ var out bytes.Buffer
14
+ var errBuf bytes.Buffer
15
+
16
+ code := Run([]string{"--help"}, &out, &errBuf)
17
+ if code != 0 {
18
+ t.Fatalf("Run returned code %d", code)
19
+ }
20
+ if !strings.Contains(out.String(), "webctx v") || !strings.Contains(out.String(), "read-link") {
21
+ t.Fatalf("unexpected help output: %q", out.String())
22
+ }
23
+ }
24
+
25
+ func TestRunVersion(t *testing.T) {
26
+ var out bytes.Buffer
27
+ var errBuf bytes.Buffer
28
+
29
+ code := Run([]string{"--version"}, &out, &errBuf)
30
+ if code != 0 {
31
+ t.Fatalf("Run returned code %d", code)
32
+ }
33
+ if strings.TrimSpace(out.String()) == "" {
34
+ t.Fatalf("unexpected empty version output")
35
+ }
36
+ if strings.Contains(out.String(), "webctx ") {
37
+ t.Fatalf("expected bare version output, got: %q", out.String())
38
+ }
39
+ }
40
+
41
+ func TestRunSearchWithoutQuery(t *testing.T) {
42
+ var out bytes.Buffer
43
+ var errBuf bytes.Buffer
44
+
45
+ code := Run([]string{"search"}, &out, &errBuf)
46
+ if code != 1 {
47
+ t.Fatalf("expected exit code 1, got %d", code)
48
+ }
49
+ if !strings.Contains(errBuf.String(), "search requires a query") {
50
+ t.Fatalf("unexpected stderr: %q", errBuf.String())
51
+ }
52
+ }
53
+
54
+ func TestNormalizeURL(t *testing.T) {
55
+ got := normalizeURL("https://Example.com/docs/?utm_source=x&ref=y&q=go")
56
+ want := "https://example.com/docs?q=go"
57
+ if got != want {
58
+ t.Fatalf("normalizeURL mismatch: got %q want %q", got, want)
59
+ }
60
+ }
61
+
62
+ func TestScoreAndRankResultsDuplicateBoost(t *testing.T) {
63
+ results, duplicatesRemoved := scoreAndRankResults([]providerDocs{
64
+ {Provider: "Brave", Docs: []SearchDoc{{URL: "https://a.com", Title: "A"}, {URL: "https://b.com", Title: "B"}}},
65
+ {Provider: "Tavily", Docs: []SearchDoc{{URL: "https://b.com", Title: "B2"}, {URL: "https://c.com", Title: "C"}}},
66
+ })
67
+ if duplicatesRemoved != 1 {
68
+ t.Fatalf("expected 1 duplicate removed, got %d", duplicatesRemoved)
69
+ }
70
+ if len(results) == 0 || results[0].URL != "https://b.com" {
71
+ t.Fatalf("expected duplicate URL to rank first, got %#v", results)
72
+ }
73
+ }
74
+
75
+ func TestScoreAndRankResultsPreservesInsertionOrderForTies(t *testing.T) {
76
+ results, _ := scoreAndRankResults([]providerDocs{
77
+ {Provider: "Brave", Docs: []SearchDoc{{URL: "https://z.example", Title: "Z"}}},
78
+ {Provider: "Tavily", Docs: []SearchDoc{{URL: "https://a.example", Title: "A"}}},
79
+ })
80
+
81
+ if len(results) != 2 || results[0].URL != "https://z.example" || results[1].URL != "https://a.example" {
82
+ t.Fatalf("expected insertion order for tied results, got %#v", results)
83
+ }
84
+ }
85
+
86
+ func TestSearchMissingCredentialsErrorIsHelpful(t *testing.T) {
87
+ t.Setenv("BRAVE_API_KEY", "")
88
+ t.Setenv("TAVILY_API_KEY", "")
89
+ t.Setenv("EXA_API_KEY", "")
90
+
91
+ text, err := Search(SearchParams{Query: "openai api"})
92
+ if err == nil {
93
+ t.Fatalf("expected error, got text %q", text)
94
+ }
95
+ if !strings.Contains(err.Error(), "missing BRAVE_API_KEY, EXA_API_KEY, TAVILY_API_KEY") {
96
+ t.Fatalf("unexpected error: %q", err.Error())
97
+ }
98
+ if !strings.Contains(err.Error(), "macOS Keychain") {
99
+ t.Fatalf("expected keychain guidance, got %q", err.Error())
100
+ }
101
+ }
102
+
103
+ func TestMapSiteMissingCredentialErrorIsHelpful(t *testing.T) {
104
+ t.Setenv("FIRECRAWL_API_KEY", "")
105
+
106
+ text, err := MapSite("https://example.com")
107
+ if err == nil {
108
+ t.Fatalf("expected error, got text %q", text)
109
+ }
110
+ if !strings.Contains(err.Error(), "missing FIRECRAWL_API_KEY") {
111
+ t.Fatalf("unexpected error: %q", err.Error())
112
+ }
113
+ if !strings.Contains(err.Error(), ".env.local next to the binary") {
114
+ t.Fatalf("expected .env.local guidance, got %q", err.Error())
115
+ }
116
+ }
117
+
118
+ func TestLoadDotEnvFileDoesNotOverrideExistingEnv(t *testing.T) {
119
+ tmpDir := t.TempDir()
120
+ envPath := filepath.Join(tmpDir, ".env.local")
121
+ if err := os.WriteFile(envPath, []byte("BRAVE_API_KEY=from-file\nEXA_API_KEY=from-file\n"), 0o644); err != nil {
122
+ t.Fatalf("write env file: %v", err)
123
+ }
124
+
125
+ t.Setenv("BRAVE_API_KEY", "from-env")
126
+ t.Setenv("EXA_API_KEY", "")
127
+
128
+ loadDotEnvFile(envPath)
129
+
130
+ if got := os.Getenv("BRAVE_API_KEY"); got != "from-env" {
131
+ t.Fatalf("expected existing env to win, got %q", got)
132
+ }
133
+ if got := os.Getenv("EXA_API_KEY"); got != "" {
134
+ t.Fatalf("expected explicitly empty env to remain untouched, got %q", got)
135
+ }
136
+ }
137
+
138
+ func TestEnvLocalCandidatesPreferExecutableDir(t *testing.T) {
139
+ originalGetwd := getwdFunc
140
+ originalExecutable := executableFunc
141
+ t.Cleanup(func() {
142
+ getwdFunc = originalGetwd
143
+ executableFunc = originalExecutable
144
+ })
145
+
146
+ getwdFunc = func() (string, error) { return "/tmp/project", nil }
147
+ executableFunc = func() (string, error) { return "/opt/webctx/bin/webctx", nil }
148
+
149
+ got := envLocalCandidates()
150
+ want := []string{
151
+ filepath.Join("/opt/webctx/bin", ".env.local"),
152
+ filepath.Join("/opt/webctx", ".env.local"),
153
+ filepath.Join("/tmp/project", ".env.local"),
154
+ }
155
+ if runtime.GOOS == "windows" {
156
+ for i := range want {
157
+ want[i] = filepath.Clean(want[i])
158
+ }
159
+ }
160
+
161
+ if len(got) != len(want) {
162
+ t.Fatalf("unexpected candidate count: got %v want %v", got, want)
163
+ }
164
+ for i := range want {
165
+ if filepath.Clean(got[i]) != filepath.Clean(want[i]) {
166
+ t.Fatalf("candidate %d mismatch: got %q want %q", i, got[i], want[i])
167
+ }
168
+ }
169
+ }
170
+
171
+ func TestLoadKeychainEnvLoadsMissingOnly(t *testing.T) {
172
+ originalLookup := keychainLookup
173
+ t.Cleanup(func() { keychainLookup = originalLookup })
174
+
175
+ keychainLookup = func(key string) (string, error) {
176
+ return "from-keychain-" + key, nil
177
+ }
178
+
179
+ t.Setenv("BRAVE_API_KEY", "already-set")
180
+ t.Setenv("TAVILY_API_KEY", "")
181
+ t.Setenv("EXA_API_KEY", "")
182
+ t.Setenv("FIRECRAWL_API_KEY", "")
183
+ t.Setenv("GH_TOKEN", "")
184
+ t.Setenv("GITHUB_TOKEN", "")
185
+
186
+ loadKeychainEnv()
187
+
188
+ if got := os.Getenv("BRAVE_API_KEY"); got != "already-set" {
189
+ t.Fatalf("expected existing env to remain, got %q", got)
190
+ }
191
+ if got := os.Getenv("TAVILY_API_KEY"); got != "" {
192
+ t.Fatalf("expected explicitly empty env to remain untouched, got %q", got)
193
+ }
194
+ }