diff --git a/Makefile b/Makefile index 12964d71..95da2315 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev daemon cli build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down +.PHONY: dev daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down MAIN_ENV_FILE ?= .env WORKTREE_ENV_FILE ?= .env.worktree @@ -15,6 +15,7 @@ POSTGRES_PORT ?= 5432 PORT ?= 8080 FRONTEND_PORT ?= 3000 FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT) +MULTICA_APP_URL ?= $(FRONTEND_ORIGIN) DATABASE_URL ?= postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@localhost:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT) NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws @@ -23,6 +24,8 @@ MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws export +MULTICA_ARGS ?= $(ARGS) + COMPOSE := docker compose define REQUIRE_ENV @@ -117,10 +120,13 @@ dev: cd server && go run ./cmd/server daemon: - cd server && go run ./cmd/multica daemon + @$(MAKE) multica MULTICA_ARGS="daemon" cli: - cd server && go run ./cmd/multica $(ARGS) + @$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)" + +multica: + cd server && go run ./cmd/multica $(MULTICA_ARGS) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) diff --git a/README.md b/README.md index f2e1399f..985ee50b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ That keeps one Docker container and one volume, while still isolating schema and |---------|-------------| | `make dev` | Run Go server (uses `PORT`, default `8080`) | | `make daemon` | Run local agent daemon | +| `make multica ARGS="version"` | Run the local `multica` CLI without installing it | | `make test` | Run Go tests | | `make build` | Build server & daemon binaries | | `make sqlc` | Regenerate sqlc code from SQL | @@ -118,6 +119,15 @@ make build cp server/bin/multica /usr/local/bin/multica # or ~/.local/bin/multica ``` +For local development, you can also run the CLI directly from the repo: + +```bash +make multica ARGS="version" +make multica ARGS="auth status" +``` + +For browser-based auth from source, make sure the local frontend is running at `FRONTEND_ORIGIN` first, for example with `make start`, `make start-main`, or `make start-worktree`. + ### Authentication ```bash diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 64f84698..642360ea 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -12,7 +12,7 @@ import { LogOut, Plus, Check, - Sparkles, + BookOpenText, SquarePen, } from "lucide-react"; import { WorkspaceAvatar } from "@/features/workspace"; @@ -51,7 +51,7 @@ const primaryNav = [ const workspaceNav = [ { href: "/agents", label: "Agents", icon: Bot }, { href: "/runtimes", label: "Runtimes", icon: Monitor }, - { href: "/skills", label: "Skills", icon: Sparkles }, + { href: "/skills", label: "Skills", icon: BookOpenText }, { href: "/settings", label: "Settings", icon: Settings }, ]; diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index c020587b..c7842059 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -10,6 +10,7 @@ import { ListTodo, Wrench, FileText, + BookOpenText, Timer, Trash2, Save, @@ -976,7 +977,7 @@ function TasksTab({ agent }: { agent: Agent }) { type DetailTab = "skills" | "tools" | "triggers" | "tasks"; const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [ - { id: "skills", label: "Skills", icon: FileText }, + { id: "skills", label: "Skills", icon: BookOpenText }, { id: "tools", label: "Tools", icon: Wrench }, { id: "triggers", label: "Triggers", icon: Timer }, { id: "tasks", label: "Tasks", icon: ListTodo }, diff --git a/apps/web/features/skills/components/skills-page.tsx b/apps/web/features/skills/components/skills-page.tsx index 8e9fd4ee..af8eb54f 100644 --- a/apps/web/features/skills/components/skills-page.tsx +++ b/apps/web/features/skills/components/skills-page.tsx @@ -398,7 +398,7 @@ function SkillDetail({ }; return ( -
+
{/* Header */}
diff --git a/scripts/init-worktree-env.sh b/scripts/init-worktree-env.sh index 38b4db73..b02ebe0f 100644 --- a/scripts/init-worktree-env.sh +++ b/scripts/init-worktree-env.sh @@ -33,6 +33,7 @@ DATABASE_URL=postgres://multica:multica@localhost:${postgres_port}/${postgres_db PORT=${backend_port} JWT_SECRET=change-me-in-production MULTICA_SERVER_URL=ws://localhost:${backend_port}/ws +MULTICA_APP_URL=${frontend_origin} GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index b8ef19be..07fe89b8 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/multica-ai/multica/server/internal/cli" + "github.com/multica-ai/multica/server/internal/daemon" ) var agentCmd = &cobra.Command{ @@ -69,18 +70,26 @@ func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { func resolveServerURL(cmd *cobra.Command) string { val := cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", "") if val != "" { - return val + return normalizeAPIBaseURL(val) } cfg, err := cli.LoadCLIConfig() if err != nil { return "http://localhost:8080" } if cfg.ServerURL != "" { - return cfg.ServerURL + return normalizeAPIBaseURL(cfg.ServerURL) } return "http://localhost:8080" } +func normalizeAPIBaseURL(raw string) string { + normalized, err := daemon.NormalizeServerBaseURL(raw) + if err == nil { + return normalized + } + return raw +} + func resolveWorkspaceID(cmd *cobra.Command) string { val := cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", "") if val != "" { diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go index 194c41e4..10503484 100644 --- a/server/cmd/multica/cmd_auth.go +++ b/server/cmd/multica/cmd_auth.go @@ -59,8 +59,10 @@ func resolveToken() string { } func resolveAppURL() string { - if val := strings.TrimSpace(os.Getenv("MULTICA_APP_URL")); val != "" { - return strings.TrimRight(val, "/") + for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} { + if val := strings.TrimSpace(os.Getenv(key)); val != "" { + return strings.TrimRight(val, "/") + } } return "http://localhost:3000" } diff --git a/server/cmd/multica/cmd_auth_test.go b/server/cmd/multica/cmd_auth_test.go new file mode 100644 index 00000000..df9b6753 --- /dev/null +++ b/server/cmd/multica/cmd_auth_test.go @@ -0,0 +1,52 @@ +package main + +import "testing" + +func TestResolveAppURL(t *testing.T) { + t.Run("prefers MULTICA_APP_URL", func(t *testing.T) { + t.Setenv("MULTICA_APP_URL", "http://localhost:14000") + t.Setenv("FRONTEND_ORIGIN", "http://localhost:13000") + + if got := resolveAppURL(); got != "http://localhost:14000" { + t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:14000") + } + }) + + t.Run("falls back to FRONTEND_ORIGIN", func(t *testing.T) { + t.Setenv("MULTICA_APP_URL", "") + t.Setenv("FRONTEND_ORIGIN", "http://localhost:13026") + + if got := resolveAppURL(); got != "http://localhost:13026" { + t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:13026") + } + }) + + t.Run("defaults to localhost 3000", func(t *testing.T) { + t.Setenv("MULTICA_APP_URL", "") + t.Setenv("FRONTEND_ORIGIN", "") + + if got := resolveAppURL(); got != "http://localhost:3000" { + t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:3000") + } + }) +} + +func TestNormalizeAPIBaseURL(t *testing.T) { + t.Run("converts websocket base URL", func(t *testing.T) { + if got := normalizeAPIBaseURL("ws://localhost:18106/ws"); got != "http://localhost:18106" { + t.Fatalf("normalizeAPIBaseURL() = %q, want %q", got, "http://localhost:18106") + } + }) + + t.Run("keeps http base URL", func(t *testing.T) { + if got := normalizeAPIBaseURL("http://localhost:8080"); got != "http://localhost:8080" { + t.Fatalf("normalizeAPIBaseURL() = %q, want %q", got, "http://localhost:8080") + } + }) + + t.Run("falls back to raw value for invalid URL", func(t *testing.T) { + if got := normalizeAPIBaseURL("://bad-url"); got != "://bad-url" { + t.Fatalf("normalizeAPIBaseURL() = %q, want %q", got, "://bad-url") + } + }) +}