feat(cli): restructure CLI commands for better UX
- Add top-level `multica login` that combines auth + workspace auto-discovery - Restructure daemon into subcommands: start, stop, status, logs - Add background daemon mode with PID management - Add daemon deregistration on shutdown (new API endpoint + SQL query) - Remove unused commands: runtime list, status, agent get/delete/stop - Make `config` show config directly instead of requiring `config show` - Update README to reflect new CLI structure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3bb79564ed
commit
38d595d81d
17 changed files with 568 additions and 276 deletions
15
README.md
15
README.md
|
|
@ -131,8 +131,9 @@ For browser-based auth from source, make sure the local frontend is running at `
|
|||
### Authentication
|
||||
|
||||
```bash
|
||||
multica auth login # Open browser to authenticate (one-click if already logged in)
|
||||
multica auth login --token # Paste a personal access token manually
|
||||
multica login # Authenticate and auto-watch your workspaces
|
||||
multica auth login # Legacy auth-only flow
|
||||
multica auth login --token # Legacy token-only auth flow
|
||||
multica auth status # Show current auth status
|
||||
multica auth logout # Remove stored token
|
||||
```
|
||||
|
|
@ -143,6 +144,7 @@ Credentials are saved to `~/.multica/config.json`.
|
|||
|
||||
```bash
|
||||
multica workspace list # List all workspaces you belong to
|
||||
multica workspace get # Show the current workspace details/context
|
||||
```
|
||||
|
||||
### Daemon Watch List
|
||||
|
|
@ -163,13 +165,13 @@ The daemon polls watched workspaces for tasks and executes them using locally in
|
|||
|
||||
```bash
|
||||
# 1. Authenticate
|
||||
multica auth login
|
||||
multica login
|
||||
|
||||
# 2. Add workspaces to watch
|
||||
multica workspace watch <workspace-id>
|
||||
|
||||
# 3. Start the daemon
|
||||
multica daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server.
|
||||
|
|
@ -178,8 +180,9 @@ The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. W
|
|||
|
||||
```bash
|
||||
multica agent list # List agents in the current workspace
|
||||
multica runtime list # List registered runtimes
|
||||
multica config show # Show CLI configuration
|
||||
multica daemon status # Show local daemon status
|
||||
multica config # Show CLI configuration
|
||||
multica config show # Compatibility alias for config display
|
||||
multica version # Show CLI version
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -24,35 +24,10 @@ var agentListCmd = &cobra.Command{
|
|||
RunE: runAgentList,
|
||||
}
|
||||
|
||||
var agentGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get agent details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentGet,
|
||||
}
|
||||
|
||||
var agentDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete an agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentDelete,
|
||||
}
|
||||
|
||||
var agentStopCmd = &cobra.Command{
|
||||
Use: "stop <id>",
|
||||
Short: "Stop an agent (set status to offline)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentStop,
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentCmd.AddCommand(agentListCmd)
|
||||
agentCmd.AddCommand(agentGetCmd)
|
||||
agentCmd.AddCommand(agentDeleteCmd)
|
||||
agentCmd.AddCommand(agentStopCmd)
|
||||
|
||||
agentListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
|
||||
|
|
@ -136,72 +111,6 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func runAgentGet(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var agent map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
|
||||
return fmt.Errorf("get agent: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "table" {
|
||||
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "DESCRIPTION"}
|
||||
rows := [][]string{{
|
||||
strVal(agent, "id"),
|
||||
strVal(agent, "name"),
|
||||
strVal(agent, "status"),
|
||||
strVal(agent, "runtime_mode"),
|
||||
strVal(agent, "description"),
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.PrintJSON(os.Stdout, agent)
|
||||
}
|
||||
|
||||
func runAgentDelete(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.DeleteJSON(ctx, "/api/agents/"+args[0]); err != nil {
|
||||
return fmt.Errorf("delete agent: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Agent %s deleted.\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentStop(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{"status": "offline"}
|
||||
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, nil); err != nil {
|
||||
return fmt.Errorf("stop agent: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Agent %s stopped.\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
func strVal(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ var authCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
var authLoginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate with Multica",
|
||||
RunE: runAuthLogin,
|
||||
Use: "login",
|
||||
Short: "Authenticate with Multica",
|
||||
Long: "Authenticate with Multica without auto-configuring workspaces. Use 'multica login' for the guided setup flow.",
|
||||
Hidden: true,
|
||||
RunE: runAuthLogin,
|
||||
}
|
||||
|
||||
var authStatusCmd = &cobra.Command{
|
||||
|
|
@ -257,7 +259,7 @@ func runAuthStatus(cmd *cobra.Command, _ []string) error {
|
|||
serverURL := resolveServerURL(cmd)
|
||||
|
||||
if token == "" {
|
||||
fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica auth login' to authenticate.")
|
||||
fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica login' to authenticate.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +273,7 @@ func runAuthStatus(cmd *cobra.Command, _ []string) error {
|
|||
Email string `json:"email"`
|
||||
}
|
||||
if err := client.GetJSON(ctx, "/api/me", &me); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica auth login' to re-authenticate.\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica login' to re-authenticate.\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
58
server/cmd/multica/cmd_compat_test.go
Normal file
58
server/cmd/multica/cmd_compat_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
func TestLegacyCompatibilityCommandsRemainAvailable(t *testing.T) {
|
||||
t.Run("auth login remains available", func(t *testing.T) {
|
||||
if _, _, err := authCmd.Find([]string{"login"}); err != nil {
|
||||
t.Fatalf("expected auth login command to exist: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workspace get remains available", func(t *testing.T) {
|
||||
if _, _, err := workspaceCmd.Find([]string{"get"}); err != nil {
|
||||
t.Fatalf("expected workspace get command to exist: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workspace members remains available", func(t *testing.T) {
|
||||
if _, _, err := workspaceCmd.Find([]string{"members"}); err != nil {
|
||||
t.Fatalf("expected workspace members command to exist: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config show and set remain available", func(t *testing.T) {
|
||||
if _, _, err := configCmd.Find([]string{"show"}); err != nil {
|
||||
t.Fatalf("expected config show command to exist: %v", err)
|
||||
}
|
||||
if _, _, err := configCmd.Find([]string{"set"}); err != nil {
|
||||
t.Fatalf("expected config set command to exist: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunConfigSetPersistsValues(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
if err := runConfigSet(nil, []string{"server_url", "http://example.com"}); err != nil {
|
||||
t.Fatalf("runConfigSet(server_url) error = %v", err)
|
||||
}
|
||||
if err := runConfigSet(nil, []string{"workspace_id", "ws-123"}); err != nil {
|
||||
t.Fatalf("runConfigSet(workspace_id) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCLIConfig() error = %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "http://example.com" {
|
||||
t.Fatalf("ServerURL = %q, want %q", cfg.ServerURL, "http://example.com")
|
||||
}
|
||||
if cfg.WorkspaceID != "ws-123" {
|
||||
t.Fatalf("WorkspaceID = %q, want %q", cfg.WorkspaceID, "ws-123")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,8 @@ import (
|
|||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage CLI configuration",
|
||||
Short: "Show CLI configuration",
|
||||
RunE: runConfigShow,
|
||||
}
|
||||
|
||||
var configShowCmd = &cobra.Command{
|
||||
|
|
|
|||
|
|
@ -2,9 +2,18 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
|
|
@ -15,22 +24,179 @@ import (
|
|||
|
||||
var daemonCmd = &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Run the local agent runtime daemon",
|
||||
Long: "Start the daemon process that polls for tasks and executes them using local agent CLIs (Claude, Codex).",
|
||||
RunE: runDaemon,
|
||||
Short: "Manage the local agent runtime daemon",
|
||||
}
|
||||
|
||||
var daemonStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start the local agent runtime daemon",
|
||||
Long: "Start the daemon process that polls for tasks and executes them using local agent CLIs (Claude, Codex).\nRuns in the background by default. Use --foreground to run in the current terminal.",
|
||||
RunE: runDaemonStart,
|
||||
}
|
||||
|
||||
var daemonStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the running daemon",
|
||||
RunE: runDaemonStop,
|
||||
}
|
||||
|
||||
var daemonStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show daemon status",
|
||||
RunE: runDaemonStatus,
|
||||
}
|
||||
|
||||
var daemonLogsCmd = &cobra.Command{
|
||||
Use: "logs",
|
||||
Short: "Show daemon logs",
|
||||
RunE: runDaemonLogs,
|
||||
}
|
||||
|
||||
func init() {
|
||||
f := daemonCmd.Flags()
|
||||
f := daemonStartCmd.Flags()
|
||||
f.Bool("foreground", false, "Run in the foreground instead of background")
|
||||
f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)")
|
||||
f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)")
|
||||
f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)")
|
||||
f.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)")
|
||||
f.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)")
|
||||
f.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)")
|
||||
|
||||
daemonLogsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
|
||||
daemonLogsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show")
|
||||
|
||||
daemonStatusCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
daemonCmd.AddCommand(daemonStartCmd)
|
||||
daemonCmd.AddCommand(daemonStopCmd)
|
||||
daemonCmd.AddCommand(daemonStatusCmd)
|
||||
daemonCmd.AddCommand(daemonLogsCmd)
|
||||
}
|
||||
|
||||
func runDaemon(cmd *cobra.Command, _ []string) error {
|
||||
// daemonDir returns the path to ~/.multica/.
|
||||
func daemonDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".multica")
|
||||
}
|
||||
|
||||
func daemonPIDPath() string {
|
||||
return filepath.Join(daemonDir(), "daemon.pid")
|
||||
}
|
||||
|
||||
func daemonLogPath() string {
|
||||
return filepath.Join(daemonDir(), "daemon.log")
|
||||
}
|
||||
|
||||
// --- daemon start ---
|
||||
|
||||
func runDaemonStart(cmd *cobra.Command, _ []string) error {
|
||||
foreground, _ := cmd.Flags().GetBool("foreground")
|
||||
if foreground {
|
||||
return runDaemonForeground(cmd)
|
||||
}
|
||||
return runDaemonBackground(cmd)
|
||||
}
|
||||
|
||||
func runDaemonBackground(cmd *cobra.Command) error {
|
||||
// Check if daemon is already running.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
health := checkDaemonHealth(ctx)
|
||||
if health["status"] == "running" {
|
||||
return fmt.Errorf("daemon is already running (pid %v)", health["pid"])
|
||||
}
|
||||
|
||||
// Resolve current executable.
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve executable path: %w", err)
|
||||
}
|
||||
|
||||
// Build child args: daemon start --foreground + forwarded flags.
|
||||
args := buildDaemonStartArgs(cmd)
|
||||
|
||||
// Ensure daemon directory exists.
|
||||
dir := daemonDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create daemon directory: %w", err)
|
||||
}
|
||||
|
||||
logPath := daemonLogPath()
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log file %s: %w", logPath, err)
|
||||
}
|
||||
|
||||
child := exec.Command(exePath, args...)
|
||||
child.Stdout = logFile
|
||||
child.Stderr = logFile
|
||||
child.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
if err := child.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
logFile.Close()
|
||||
|
||||
// Detach: we don't Wait() on the child — it runs independently.
|
||||
child.Process.Release()
|
||||
|
||||
// Write PID file.
|
||||
pidPath := daemonPIDPath()
|
||||
if err := os.WriteFile(pidPath, []byte(strconv.Itoa(child.Process.Pid)), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not write PID file: %v\n", err)
|
||||
}
|
||||
|
||||
// Wait briefly and verify daemon started via health endpoint.
|
||||
time.Sleep(2 * time.Second)
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel2()
|
||||
health = checkDaemonHealth(ctx2)
|
||||
if health["status"] != "running" {
|
||||
fmt.Fprintf(os.Stderr, "Daemon may not have started successfully. Check logs:\n %s\n", logPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Daemon started (pid %d)\n", child.Process.Pid)
|
||||
fmt.Fprintf(os.Stderr, "Logs: %s\n", logPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDaemonStartArgs constructs args for the background child process.
|
||||
func buildDaemonStartArgs(cmd *cobra.Command) []string {
|
||||
args := []string{"daemon", "start", "--foreground"}
|
||||
|
||||
if v := flagString(cmd, "daemon-id"); v != "" {
|
||||
args = append(args, "--daemon-id", v)
|
||||
}
|
||||
if v := flagString(cmd, "device-name"); v != "" {
|
||||
args = append(args, "--device-name", v)
|
||||
}
|
||||
if v := flagString(cmd, "runtime-name"); v != "" {
|
||||
args = append(args, "--runtime-name", v)
|
||||
}
|
||||
if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 {
|
||||
args = append(args, "--poll-interval", d.String())
|
||||
}
|
||||
if d, _ := cmd.Flags().GetDuration("heartbeat-interval"); d > 0 {
|
||||
args = append(args, "--heartbeat-interval", d.String())
|
||||
}
|
||||
if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 {
|
||||
args = append(args, "--agent-timeout", d.String())
|
||||
}
|
||||
|
||||
// Forward global persistent flags.
|
||||
if v, _ := cmd.Flags().GetString("server-url"); v != "" {
|
||||
args = append(args, "--server-url", v)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func runDaemonForeground(cmd *cobra.Command) error {
|
||||
overrides := daemon.Overrides{
|
||||
ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""),
|
||||
DaemonID: flagString(cmd, "daemon-id"),
|
||||
|
|
@ -58,12 +224,142 @@ func runDaemon(cmd *cobra.Command, _ []string) error {
|
|||
logger := logger_pkg.NewLogger("daemon")
|
||||
d := daemon.New(cfg, logger)
|
||||
|
||||
// Write PID file so "daemon stop" can find us.
|
||||
if dir := daemonDir(); dir != "" {
|
||||
os.MkdirAll(dir, 0o755)
|
||||
os.WriteFile(daemonPIDPath(), []byte(strconv.Itoa(os.Getpid())), 0o644)
|
||||
}
|
||||
defer os.Remove(daemonPIDPath())
|
||||
|
||||
if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- daemon stop ---
|
||||
|
||||
func runDaemonStop(_ *cobra.Command, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
health := checkDaemonHealth(ctx)
|
||||
if health["status"] != "running" {
|
||||
fmt.Fprintln(os.Stderr, "Daemon is not running.")
|
||||
return nil
|
||||
}
|
||||
|
||||
pid, ok := health["pid"].(float64)
|
||||
if !ok || pid == 0 {
|
||||
return fmt.Errorf("could not determine daemon PID from health endpoint")
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(int(pid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("find process %d: %w", int(pid), err)
|
||||
}
|
||||
|
||||
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("stop daemon (pid %d): %w", int(pid), err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid))
|
||||
|
||||
// Poll health endpoint until daemon is gone.
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
h := checkDaemonHealth(ctx2)
|
||||
cancel2()
|
||||
if h["status"] != "running" {
|
||||
os.Remove(daemonPIDPath())
|
||||
fmt.Fprintln(os.Stderr, "Daemon stopped.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "Daemon is still stopping. It may be finishing a running task.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- daemon status ---
|
||||
|
||||
func runDaemonStatus(cmd *cobra.Command, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
health := checkDaemonHealth(ctx)
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, health)
|
||||
}
|
||||
|
||||
if health["status"] != "running" {
|
||||
fmt.Fprintln(os.Stdout, "Daemon: stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "Daemon: running (pid %v, uptime %v)\n", health["pid"], health["uptime"])
|
||||
if agents, ok := health["agents"].([]any); ok && len(agents) > 0 {
|
||||
parts := make([]string, len(agents))
|
||||
for i, a := range agents {
|
||||
parts[i] = fmt.Sprint(a)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Agents: %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
if ws, ok := health["workspaces"].([]any); ok {
|
||||
fmt.Fprintf(os.Stdout, "Workspaces: %d\n", len(ws))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- daemon logs ---
|
||||
|
||||
func runDaemonLogs(cmd *cobra.Command, _ []string) error {
|
||||
logPath := daemonLogPath()
|
||||
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no log file found at %s\nThe daemon may not have been started in background mode", logPath)
|
||||
}
|
||||
|
||||
follow, _ := cmd.Flags().GetBool("follow")
|
||||
lines, _ := cmd.Flags().GetInt("lines")
|
||||
|
||||
args := []string{"-n", strconv.Itoa(lines)}
|
||||
if follow {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
args = append(args, logPath)
|
||||
|
||||
tail := exec.Command("tail", args...)
|
||||
tail.Stdout = os.Stdout
|
||||
tail.Stderr = os.Stderr
|
||||
return tail.Run()
|
||||
}
|
||||
|
||||
// checkDaemonHealth calls the daemon's local health endpoint.
|
||||
func checkDaemonHealth(ctx context.Context) map[string]any {
|
||||
addr := fmt.Sprintf("http://127.0.0.1:%d/health", daemon.DefaultHealthPort)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil)
|
||||
if err != nil {
|
||||
return map[string]any{"status": "stopped"}
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return map[string]any{"status": "stopped"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return map[string]any{"status": "stopped"}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// flagString returns a string flag value or empty string.
|
||||
func flagString(cmd *cobra.Command, name string) string {
|
||||
val, _ := cmd.Flags().GetString(name)
|
||||
return val
|
||||
|
|
|
|||
97
server/cmd/multica/cmd_login.go
Normal file
97
server/cmd/multica/cmd_login.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var loginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate and set up workspaces",
|
||||
Long: "Log in to Multica, then automatically discover and watch all your workspaces.",
|
||||
RunE: runLogin,
|
||||
}
|
||||
|
||||
func init() {
|
||||
loginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token")
|
||||
}
|
||||
|
||||
func runLogin(cmd *cobra.Command, args []string) error {
|
||||
// Run the standard auth login flow.
|
||||
if err := runAuthLogin(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-discover and watch all workspaces.
|
||||
if err := autoWatchWorkspaces(cmd); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nCould not auto-configure workspaces: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Run 'multica workspace list' and 'multica workspace watch <id>' to set up manually.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n→ Run 'multica daemon start' to start your local agent runtime.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoWatchWorkspaces(cmd *cobra.Command) error {
|
||||
serverURL := resolveServerURL(cmd)
|
||||
token := resolveToken()
|
||||
if token == "" {
|
||||
return fmt.Errorf("not authenticated")
|
||||
}
|
||||
|
||||
client := cli.NewAPIClient(serverURL, "", token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var workspaces []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := client.GetJSON(ctx, "/api/workspaces", &workspaces); err != nil {
|
||||
return fmt.Errorf("list workspaces: %w", err)
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "\nNo workspaces found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var added int
|
||||
for _, ws := range workspaces {
|
||||
if cfg.AddWatchedWorkspace(ws.ID, ws.Name) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
// Set default workspace if not set.
|
||||
if cfg.WorkspaceID == "" {
|
||||
cfg.WorkspaceID = workspaces[0].ID
|
||||
}
|
||||
|
||||
if err := cli.SaveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if added > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\nWatching %d workspace(s):\n", len(workspaces))
|
||||
for _, ws := range workspaces {
|
||||
fmt.Fprintf(os.Stderr, " • %s (%s)\n", ws.Name, ws.ID)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "\nAll %d workspace(s) already watched.\n", len(workspaces))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var runtimeCmd = &cobra.Command{
|
||||
Use: "runtime",
|
||||
Short: "Manage agent runtimes",
|
||||
}
|
||||
|
||||
var runtimeListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List agent runtimes",
|
||||
RunE: runRuntimeList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
runtimeCmd.AddCommand(runtimeListCmd)
|
||||
|
||||
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
}
|
||||
|
||||
func runRuntimeList(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var runtimes []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
|
||||
return fmt.Errorf("list runtimes: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, runtimes)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "PROVIDER", "STATUS", "DEVICE"}
|
||||
rows := make([][]string, 0, len(runtimes))
|
||||
for _, r := range runtimes {
|
||||
rows = append(rows, []string{
|
||||
strVal(r, "id"),
|
||||
strVal(r, "name"),
|
||||
strVal(r, "provider"),
|
||||
strVal(r, "status"),
|
||||
strVal(r, "device_info"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
"github.com/multica-ai/multica/server/internal/daemon"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check server and daemon status",
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
func init() {
|
||||
statusCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check server health.
|
||||
serverStatus := "unreachable"
|
||||
body, err := client.HealthCheck(ctx)
|
||||
if err == nil {
|
||||
serverStatus = strings.TrimSpace(body)
|
||||
}
|
||||
|
||||
// Check local daemon via its health endpoint.
|
||||
daemonHealth := checkDaemonHealth(ctx)
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
result := map[string]any{
|
||||
"server": map[string]any{
|
||||
"url": client.BaseURL,
|
||||
"status": serverStatus,
|
||||
},
|
||||
"daemon": daemonHealth,
|
||||
}
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "Server: %s (%s)\n", serverStatus, client.BaseURL)
|
||||
if daemonHealth["status"] == "running" {
|
||||
fmt.Fprintf(os.Stdout, "Daemon: running (pid %v, uptime %v)\n", daemonHealth["pid"], daemonHealth["uptime"])
|
||||
if agents, ok := daemonHealth["agents"].([]any); ok && len(agents) > 0 {
|
||||
parts := make([]string, len(agents))
|
||||
for i, a := range agents {
|
||||
parts[i] = fmt.Sprint(a)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, " Agents: %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
if ws, ok := daemonHealth["workspaces"].([]any); ok {
|
||||
fmt.Fprintf(os.Stdout, " Workspaces: %d\n", len(ws))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stdout, "Daemon: stopped\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDaemonHealth calls the daemon's local health endpoint.
|
||||
func checkDaemonHealth(ctx context.Context) map[string]any {
|
||||
addr := fmt.Sprintf("http://127.0.0.1:%d/health", daemon.DefaultHealthPort)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil)
|
||||
if err != nil {
|
||||
return map[string]any{"status": "stopped"}
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return map[string]any{"status": "stopped"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return map[string]any{"status": "stopped"}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ func runWorkspaceList(cmd *cobra.Command, _ []string) error {
|
|||
serverURL := resolveServerURL(cmd)
|
||||
token := resolveToken()
|
||||
if token == "" {
|
||||
return fmt.Errorf("not authenticated: run 'multica auth login' first")
|
||||
return fmt.Errorf("not authenticated: run 'multica login' first")
|
||||
}
|
||||
|
||||
client := cli.NewAPIClient(serverURL, "", token)
|
||||
|
|
@ -203,7 +203,7 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
|||
serverURL := resolveServerURL(cmd)
|
||||
token := resolveToken()
|
||||
if token == "" {
|
||||
return fmt.Errorf("not authenticated: run 'multica auth login' first")
|
||||
return fmt.Errorf("not authenticated: run 'multica login' first")
|
||||
}
|
||||
|
||||
client := cli.NewAPIClient(serverURL, "", token)
|
||||
|
|
|
|||
|
|
@ -24,14 +24,13 @@ func init() {
|
|||
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
|
||||
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
|
||||
|
||||
rootCmd.AddCommand(loginCmd)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
rootCmd.AddCommand(agentCmd)
|
||||
rootCmd.AddCommand(runtimeCmd)
|
||||
rootCmd.AddCommand(workspaceCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(issueCmd)
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession)
|
||||
|
||||
r.Post("/register", h.DaemonRegister)
|
||||
r.Post("/deregister", h.DaemonDeregister)
|
||||
r.Post("/heartbeat", h.DaemonHeartbeat)
|
||||
|
||||
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ func (c *Client) ReportPingResult(ctx context.Context, runtimeID, pingID string,
|
|||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/ping/%s/result", runtimeID, pingID), result, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error {
|
||||
return c.postJSON(ctx, "/api/daemon/deregister", map[string]any{
|
||||
"runtime_ids": runtimeIDs,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) {
|
||||
var resp struct {
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||
return fmt.Errorf("no runtimes registered")
|
||||
}
|
||||
|
||||
// Deregister runtimes on shutdown (uses a fresh context since ctx will be cancelled).
|
||||
defer d.deregisterRuntimes()
|
||||
|
||||
// Start config watcher for hot-reload.
|
||||
go d.configWatchLoop(ctx)
|
||||
|
||||
|
|
@ -82,6 +85,23 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||
return d.pollLoop(ctx)
|
||||
}
|
||||
|
||||
// deregisterRuntimes notifies the server that all runtimes are going offline.
|
||||
func (d *Daemon) deregisterRuntimes() {
|
||||
runtimeIDs := d.allRuntimeIDs()
|
||||
if len(runtimeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := d.client.Deregister(ctx, runtimeIDs); err != nil {
|
||||
d.logger.Warn("failed to deregister runtimes on shutdown", "error", err)
|
||||
} else {
|
||||
d.logger.Info("deregistered runtimes", "count", len(runtimeIDs))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAuth loads the auth token from the CLI config.
|
||||
func (d *Daemon) resolveAuth() error {
|
||||
cfg, err := cli.LoadCLIConfig()
|
||||
|
|
@ -89,8 +109,8 @@ func (d *Daemon) resolveAuth() error {
|
|||
return fmt.Errorf("load CLI config: %w", err)
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
d.logger.Warn("not authenticated — run 'multica auth login' to authenticate, then restart the daemon")
|
||||
return fmt.Errorf("not authenticated: run 'multica auth login' first")
|
||||
d.logger.Warn("not authenticated — run 'multica login' to authenticate, then restart the daemon")
|
||||
return fmt.Errorf("not authenticated: run 'multica login' first")
|
||||
}
|
||||
d.client.SetToken(cfg.Token)
|
||||
d.logger.Info("authenticated")
|
||||
|
|
@ -105,7 +125,7 @@ func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
|
|||
}
|
||||
|
||||
if len(cfg.WatchedWorkspaces) == 0 {
|
||||
return fmt.Errorf("no watched workspaces configured: run 'multica watch <id>' to add one")
|
||||
return fmt.Errorf("no watched workspaces configured: run 'multica workspace watch <id>' to add one")
|
||||
}
|
||||
|
||||
var registered int
|
||||
|
|
|
|||
|
|
@ -109,6 +109,51 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp})
|
||||
}
|
||||
|
||||
// DaemonDeregister marks runtimes as offline when the daemon shuts down.
|
||||
func (h *Handler) DaemonDeregister(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RuntimeIDs []string `json:"runtime_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.RuntimeIDs) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "runtime_ids is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Track affected workspaces for WS notifications.
|
||||
affectedWorkspaces := make(map[string]bool)
|
||||
|
||||
for _, rid := range req.RuntimeIDs {
|
||||
// Look up the runtime to find its workspace.
|
||||
rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(rid))
|
||||
if err != nil {
|
||||
slog.Warn("deregister: runtime not found", "runtime_id", rid, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.Queries.SetAgentRuntimeOffline(r.Context(), parseUUID(rid)); err != nil {
|
||||
slog.Warn("deregister: failed to set offline", "runtime_id", rid, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
affectedWorkspaces[uuidToString(rt.WorkspaceID)] = true
|
||||
}
|
||||
|
||||
// Notify frontend clients so they re-fetch runtime list.
|
||||
for wsID := range affectedWorkspaces {
|
||||
h.publish(protocol.EventDaemonRegister, wsID, "system", "", map[string]any{
|
||||
"action": "deregister",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("daemon deregistered", "runtime_ids", req.RuntimeIDs)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type DaemonHeartbeatRequest struct {
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,17 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const setAgentRuntimeOffline = `-- name: SetAgentRuntimeOffline :exec
|
||||
UPDATE agent_runtime
|
||||
SET status = 'offline', updated_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) SetAgentRuntimeOffline(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, setAgentRuntimeOffline, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateAgentRuntimeHeartbeat = `-- name: UpdateAgentRuntimeHeartbeat :one
|
||||
UPDATE agent_runtime
|
||||
SET status = 'online', last_seen_at = now(), updated_at = now()
|
||||
|
|
|
|||
|
|
@ -39,3 +39,8 @@ UPDATE agent_runtime
|
|||
SET status = 'online', last_seen_at = now(), updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetAgentRuntimeOffline :exec
|
||||
UPDATE agent_runtime
|
||||
SET status = 'offline', updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue