Skip to content

Commit 4604200

Browse files
enowdevclaude
authored andcommitted
Release v0.1.5: merge develop → main
Highlights since v0.1.4: Claude Code provider (clc/ prefix), Integrations app (connect CLI coding tools), MITM (route Antigravity/Copilot through the gateway), per-provider sticky/round-robin account rotation, account de-dup + email backfill, plugins gated to Premium, skill list/remove/update, Gmail store client, plus terminal-profile and OTP/file-manager fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122jfYFrAhahVut2aT2PtJi
2 parents 2f3d8c5 + 982a8d0 commit 4604200

114 files changed

Lines changed: 8814 additions & 378 deletions

File tree

Some content is hidden

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

cmd/enowx/cli.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ func httpJSON(method, url string) (map[string]any, int, error) {
4444
Error string `json:"error"`
4545
}
4646
_ = json.Unmarshal(b, &env)
47+
// Surface the server's error message (e.g. the update's "reinstall to
48+
// ~/.local/bin" guidance) as a real error so callers can print it verbatim.
49+
if resp.StatusCode >= 300 && env.Error != "" {
50+
return env.Data, resp.StatusCode, fmt.Errorf("%s", env.Error)
51+
}
4752
if env.Data == nil {
4853
// Some endpoints (e.g. /health) aren't enveloped.
4954
var raw map[string]any
@@ -210,10 +215,14 @@ func updateCmd(args []string) {
210215
return
211216
}
212217
fmt.Println("downloading + installing…")
213-
if _, code, err := httpJSON(http.MethodPost, base(cfg)+"/api/update"); err != nil || code >= 300 {
218+
data, code, err := httpJSON(http.MethodPost, base(cfg)+"/api/update")
219+
if err != nil || code >= 300 {
214220
fmt.Fprintf(os.Stderr, "update failed (%d): %v\n", code, err)
215221
os.Exit(1)
216222
}
223+
if note, _ := data["note"].(string); note != "" {
224+
fmt.Println("\n" + note)
225+
}
217226
fmt.Println("update applied — enx will restart itself")
218227
}
219228

@@ -285,6 +294,9 @@ Usage:
285294
enx tunnel stop tear the tunnel down
286295
enx tunnel status show the current tunnel URL
287296
enx skill install <slug> [-g] install a skill (-g = global, else project)
297+
enx skill list list installed skills
298+
enx skill remove <slug> uninstall a skill
299+
enx skill update <slug> reinstall a skill to the latest version
288300
enx version print the version
289301
`)
290302
}

cmd/enowx/main.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,27 @@ package main
1010

1111
import (
1212
"context"
13+
"errors"
1314
"fmt"
1415
"log"
1516
"os"
17+
"path/filepath"
1618
"os/signal"
1719
"runtime/debug"
1820
"syscall"
1921
"time"
2022

2123
"github.com/enowdev/enowx/config"
2224
"github.com/enowdev/enowx/core/daemon"
25+
"github.com/enowdev/enowx/core/mitm"
2326
"github.com/enowdev/enowx/core/plugins"
2427
"github.com/enowdev/enowx/core/pool"
2528
"github.com/enowdev/enowx/core/provider"
2629
"github.com/enowdev/enowx/core/provider/antigravity"
2730
"github.com/enowdev/enowx/core/provider/codebuddy"
2831
"github.com/enowdev/enowx/core/provider/commandcode"
2932
"github.com/enowdev/enowx/core/provider/custommgr"
33+
"github.com/enowdev/enowx/core/provider/claudecode"
3034
"github.com/enowdev/enowx/core/provider/codex"
3135
leonardoprovider "github.com/enowdev/enowx/core/provider/leonardo"
3236
sunoprovider "github.com/enowdev/enowx/core/provider/suno"
@@ -43,6 +47,14 @@ import (
4347
var version = "dev"
4448

4549
func main() {
50+
// Hidden subcommand: the elevated MITM proxy child. Spawned (with an admin
51+
// prompt) by the main process so only this small child runs as root — it binds
52+
// :443, installs the CA + hosts entries, and serves the intercept proxy.
53+
if len(os.Args) > 1 && os.Args[1] == "__mitm-serve" {
54+
mitm.RunElevatedServe(os.Args[2:])
55+
return
56+
}
57+
4658
// The detached daemon child (ENOWX_DAEMON=1) always runs the server.
4759
if daemon.IsDaemon() {
4860
runServer()
@@ -112,15 +124,46 @@ func runServer() {
112124
reg.Register(commandcode.New(doer))
113125
reg.Register(kiro.New(doer, saveCreds))
114126
reg.Register(codex.New(doer, saveCreds))
127+
reg.Register(claudecode.New(doer, saveCreds))
115128
reg.Register(antigravity.New(doer, saveCreds))
116129
reg.Register(sunoprovider.New(doer))
117130
reg.Register(leonardoprovider.New(doer))
118131

119-
px := proxy.New(reg, pool.New(db.Accounts()), doer)
132+
px := proxy.New(reg, pool.New(db.Accounts()).WithSettings(db.Settings()), doer)
120133
tun := tunnel.New(cfg.RuntimeDir, cfg.Port)
121134
pluginMgr := plugins.New(cfg.PluginsDir(), cfg.Port)
122135
syncMgr := syncpkg.New(db.Settings(), db.Music(), db.Logs())
123136

137+
// MITM: intercept a proprietary IDE's hardcoded endpoint and reroute it to us.
138+
// The gateway URL is our own loopback; the API key is resolved lazily from the
139+
// key store (first enabled key).
140+
mitmMgr := mitm.New(
141+
filepath.Join(cfg.RuntimeDir, "mitm"),
142+
fmt.Sprintf("http://127.0.0.1:%d", cfg.Port),
143+
func() string {
144+
rows, err := db.Keys().List(context.Background())
145+
if err == nil {
146+
for _, k := range rows {
147+
if k.Enabled && k.Secret != "" {
148+
return k.Secret
149+
}
150+
}
151+
}
152+
return ""
153+
},
154+
)
155+
156+
// Plugins are premium-only: a plugin can only run/serve when the signed-in
157+
// user has the entitlement (checked before the sidecar spawns and before the
158+
// UI is served), so sharing a plugin and installing it manually doesn't bypass
159+
// the paywall.
160+
pluginMgr.SetGate(func() error {
161+
if syncMgr.HasEntitlement(context.Background(), "plugins.use") {
162+
return nil
163+
}
164+
return errors.New("plugins are a Premium feature — upgrade to use them")
165+
})
166+
124167
// User-defined (custom) providers: register the stored ones live, then keep
125168
// the registry/prefix/catalog in sync on change.
126169
customMgr := custommgr.New(reg, db.CustomProviders(), custommgr.Catalog{
@@ -133,7 +176,7 @@ func runServer() {
133176

134177
// Full cloud sync: let the sync manager snapshot/apply accounts, gateway
135178
// keys, aliases, and custom providers (custom providers register live).
136-
syncMgr.SetFullSync(db.Accounts(), db.Keys(), db.Aliases(), db.CustomProviders(), db.Proxies(),
179+
syncMgr.SetFullSync(db.Accounts(), db.Keys(), db.Aliases(), db.Combos(), db.CustomProviders(), db.Proxies(),
137180
customMgr.RegisterOne, customMgr.UnregisterOne)
138181
// Maintain the live channel (pull side) and the automatic push side. Both
139182
// are no-ops until logged in; auto-push also obeys the global toggle.
@@ -151,9 +194,11 @@ func runServer() {
151194
Music: db.Music(),
152195
SettingsKV: db.Settings(),
153196
Aliases: db.Aliases(),
197+
Combos: db.Combos(),
154198
ApiTest: db.ApiTest(),
155199
Tunnel: tun,
156200
Plugins: pluginMgr,
201+
MITM: mitmMgr,
157202
Sync: syncMgr,
158203
CustomProv: customMgr,
159204
Filters: db.Filters(),

cmd/enowx/skill.go

Lines changed: 181 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import (
1010

1111
const skillRepo = "https://github.com/enowdev/enowX-Skill.git"
1212

13-
// skillCmd handles `enx skill install <slug> [-g]`.
13+
// skillCmd handles `enx skill install|list|remove|update <slug> [-g]`.
1414
//
1515
// enx skill install <slug> install into ./.agents/skill/<slug> (project)
1616
// enx skill install <slug> -g install into ~/.agents/skill/<slug> (global)
17+
// enx skill list list installed skills
18+
// enx skill remove <slug> uninstall a skill
19+
// enx skill update <slug> reinstall to the latest version
1720
func skillCmd(args []string) {
1821
sub := ""
1922
if len(args) > 0 {
@@ -23,8 +26,17 @@ func skillCmd(args []string) {
2326
switch sub {
2427
case "install", "add":
2528
skillInstall(args)
29+
case "list":
30+
skillList()
31+
case "remove", "rm", "uninstall":
32+
skillRemove(args)
33+
case "update", "upgrade":
34+
skillUpdate(args)
2635
default:
2736
fmt.Fprintln(os.Stderr, "usage: enx skill install <slug> [-g]")
37+
fmt.Fprintln(os.Stderr, " enx skill list")
38+
fmt.Fprintln(os.Stderr, " enx skill remove <slug>")
39+
fmt.Fprintln(os.Stderr, " enx skill update <slug>")
2840
os.Exit(1)
2941
}
3042
}
@@ -48,6 +60,10 @@ func skillInstall(args []string) {
4860
fmt.Fprintln(os.Stderr, "usage: enx skill install <slug> [-g]")
4961
os.Exit(1)
5062
}
63+
if !validSkillSlug(slug) {
64+
fmt.Fprintf(os.Stderr, "invalid skill name %q\n", slug)
65+
os.Exit(1)
66+
}
5167

5268
// git is required for the sparse checkout.
5369
if _, err := exec.LookPath("git"); err != nil {
@@ -56,15 +72,9 @@ func skillInstall(args []string) {
5672
}
5773

5874
// Destination: ~/.agents/skill/<slug> (global) or ./.agents/skill/<slug>.
59-
base := ".agents"
75+
base := skillBase(global)
6076
scope := "this project"
6177
if global {
62-
home, err := os.UserHomeDir()
63-
if err != nil {
64-
fmt.Fprintf(os.Stderr, "cannot resolve home directory: %v\n", err)
65-
os.Exit(1)
66-
}
67-
base = filepath.Join(home, ".agents")
6878
scope = "globally"
6979
}
7080
dest := filepath.Join(base, "skill", slug)
@@ -117,6 +127,169 @@ func skillInstall(args []string) {
117127
fmt.Printf("✓ installed %q → %s\n", slug, dest)
118128
}
119129

130+
// skillList prints all installed skills (global + project).
131+
func skillList() {
132+
type entry struct {
133+
slug string
134+
scope string
135+
path string
136+
}
137+
var entries []entry
138+
dirs := []struct {
139+
label string
140+
dir string
141+
}{
142+
{"global", filepath.Join(skillBase(true), "skill")},
143+
{"project", filepath.Join(skillBase(false), "skill")},
144+
}
145+
for _, d := range dirs {
146+
items, err := os.ReadDir(d.dir)
147+
if err != nil {
148+
continue
149+
}
150+
for _, e := range items {
151+
if e.IsDir() {
152+
entries = append(entries, entry{
153+
slug: e.Name(),
154+
scope: d.label,
155+
path: filepath.Join(d.dir, e.Name()),
156+
})
157+
}
158+
}
159+
}
160+
if len(entries) == 0 {
161+
fmt.Println("no skills installed")
162+
return
163+
}
164+
fmt.Printf("%-24s %-8s %s\n", "slug", "scope", "path")
165+
fmt.Println(strings.Repeat("-", 72))
166+
for _, e := range entries {
167+
fmt.Printf("%-24s %-8s %s\n", e.slug, e.scope, e.path)
168+
}
169+
}
170+
171+
// skillRemove uninstalls a skill by slug.
172+
func skillRemove(args []string) {
173+
var slug string
174+
global := false
175+
for _, a := range args {
176+
switch a {
177+
case "-g", "--global":
178+
global = true
179+
default:
180+
if strings.HasPrefix(a, "-") {
181+
fmt.Fprintf(os.Stderr, "unknown flag %q\n", a)
182+
os.Exit(1)
183+
}
184+
slug = a
185+
}
186+
}
187+
if slug == "" {
188+
fmt.Fprintln(os.Stderr, "usage: enx skill remove <slug> [-g]")
189+
os.Exit(1)
190+
}
191+
if !validSkillSlug(slug) {
192+
fmt.Fprintf(os.Stderr, "invalid skill name %q\n", slug)
193+
os.Exit(1)
194+
}
195+
base := skillBase(global)
196+
dest := filepath.Join(base, "skill", slug)
197+
if _, err := os.Stat(dest); err != nil {
198+
if global {
199+
fmt.Fprintf(os.Stderr, "skill %q is not installed globally\n", slug)
200+
os.Exit(1)
201+
}
202+
// Try global as fallback.
203+
globalDest := filepath.Join(skillBase(true), "skill", slug)
204+
if _, err2 := os.Stat(globalDest); err2 == nil {
205+
dest = globalDest
206+
global = true
207+
} else {
208+
fmt.Fprintf(os.Stderr, "skill %q is not installed\n", slug)
209+
os.Exit(1)
210+
}
211+
}
212+
scope := "project"
213+
if global {
214+
scope = "global"
215+
}
216+
if err := os.RemoveAll(dest); err != nil {
217+
fmt.Fprintf(os.Stderr, "remove failed: %v\n", err)
218+
os.Exit(1)
219+
}
220+
fmt.Printf("✓ removed %q (%s)\n", slug, scope)
221+
}
222+
223+
// skillUpdate reinstalls a skill (remove + install).
224+
func skillUpdate(args []string) {
225+
var slug string
226+
global := false
227+
for _, a := range args {
228+
switch a {
229+
case "-g", "--global":
230+
global = true
231+
default:
232+
if strings.HasPrefix(a, "-") {
233+
fmt.Fprintf(os.Stderr, "unknown flag %q\n", a)
234+
os.Exit(1)
235+
}
236+
slug = a
237+
}
238+
}
239+
if slug == "" {
240+
fmt.Fprintln(os.Stderr, "usage: enx skill update <slug> [-g]")
241+
os.Exit(1)
242+
}
243+
if !validSkillSlug(slug) {
244+
fmt.Fprintf(os.Stderr, "invalid skill name %q\n", slug)
245+
os.Exit(1)
246+
}
247+
// Check that git is available.
248+
if _, err := exec.LookPath("git"); err != nil {
249+
fmt.Fprintln(os.Stderr, "git is required to update skills")
250+
os.Exit(1)
251+
}
252+
// Remove existing.
253+
base := skillBase(global)
254+
dest := filepath.Join(base, "skill", slug)
255+
if _, err := os.Stat(dest); err == nil {
256+
_ = os.RemoveAll(dest)
257+
}
258+
// Reinstall.
259+
skillInstall(args)
260+
}
261+
262+
// validSkillSlug reports whether a slug is a safe single path segment (letters,
263+
// digits, dot, underscore, hyphen) — so it can't escape the skill directory. It
264+
// mirrors the community skill naming and guards remove/update's RemoveAll.
265+
func validSkillSlug(slug string) bool {
266+
if slug == "" || slug == "." || slug == ".." {
267+
return false
268+
}
269+
for _, r := range slug {
270+
switch {
271+
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
272+
r == '-', r == '_', r == '.':
273+
default:
274+
return false
275+
}
276+
}
277+
return true
278+
}
279+
280+
// skillBase returns the base agents directory for global or project scope.
281+
func skillBase(global bool) string {
282+
if global {
283+
home, err := os.UserHomeDir()
284+
if err != nil {
285+
fmt.Fprintf(os.Stderr, "cannot resolve home directory: %v\n", err)
286+
os.Exit(1)
287+
}
288+
return filepath.Join(home, ".agents")
289+
}
290+
return ".agents"
291+
}
292+
120293
// copyDir recursively copies src to dst (fallback when os.Rename can't cross fs).
121294
func copyDir(src, dst string) error {
122295
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {

core/convert/openai.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ func ToOpenAIChat(req *model.Request) []byte {
8383
"model": req.Model,
8484
"stream": req.Stream,
8585
}
86+
// Ask the upstream to include a usage block in the final SSE chunk so token
87+
// counts are available when streaming (needed for accurate accounting).
88+
if req.Stream {
89+
out["stream_options"] = map[string]any{"include_usage": true}
90+
}
8691
if req.MaxTokens > 0 {
8792
out["max_tokens"] = req.MaxTokens
8893
}

0 commit comments

Comments
 (0)