Improve local CLI auth and skills UX

This commit is contained in:
Jiayuan 2026-03-27 18:32:56 +08:00
parent 8bd476f47c
commit ed426872cc
9 changed files with 92 additions and 11 deletions

View file

@ -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)

View file

@ -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

View file

@ -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 },
];

View file

@ -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 },

View file

@ -398,7 +398,7 @@ function SkillDetail({
};
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-full min-h-0 flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3">

View file

@ -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=

View file

@ -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 != "" {

View file

@ -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"
}

View file

@ -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")
}
})
}