mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
/ unset-path for a per-machine command override. protocol-family validated
client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
validated; update only sends changed flags (protocol_family immutable);
delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
appendProfileRuntimes now prefers an override path when set AND executable,
else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
relative-path rejection, config preservation); cli/config round-trip;
daemon prefers-override / falls-back-when-not-executable.
Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page
Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).
- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
RuntimeProtocolFamily / RuntimeProfile types; api client
list/get/create/update/deleteRuntimeProfile against
/api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
protocol family → step 2 display_name/command_name/description; edit form
for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
create/edit/delete flow is keyboard + screen-reader operable.
Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.
Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)
Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".
- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
detail row, the create body field, the update patch field, and the form
textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
update and stopped sending `fixed_args`; updated the "no fields" message.
Test now asserts the CLI never sends fixed_args.
Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.
Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)
Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.
- Server (runtime_profile.go): drop `visibility` from the create + update
request structs; CreateRuntimeProfile always stores 'workspace'
(runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
removed validRuntimeProfileVisibility. The column + response field stay
(always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
the VisibilityOption component, the detail row, the visibility state, and the
create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
(profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
the VISIBILITY list column; removed validateVisibility; stopped sending the
field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
visibility:"private" -> response and DB row are 'workspace'); CLI create test
now asserts visibility is never sent.
Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.
Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
186 lines
7.2 KiB
Go
186 lines
7.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
const defaultCLIConfigPath = ".multica/config.json"
|
|
|
|
// CLIConfig holds persistent CLI settings.
|
|
type CLIConfig struct {
|
|
ServerURL string `json:"server_url,omitempty"`
|
|
AppURL string `json:"app_url,omitempty"`
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
|
|
// Backends contains per-backend overrides for users who want to point
|
|
// the daemon at non-default tool installations (e.g. an OpenClaw bundled
|
|
// inside another desktop app, or multiple isolated profiles on the same
|
|
// machine). Empty / absent means "discover from PATH and use vendor
|
|
// defaults" — the historical behavior. See issue #3875.
|
|
Backends *BackendOverrides `json:"backends,omitempty"`
|
|
|
|
// ProfileCommandOverrides is a per-machine map of custom runtime
|
|
// profile_id -> absolute executable path (MUL-3284). A workspace custom
|
|
// runtime profile records the command_name the daemon resolves on PATH,
|
|
// but the same logical profile may live at a different path on each
|
|
// machine (or not be on PATH at all). This map lets an operator pin the
|
|
// exact binary for a profile on this host via
|
|
// `multica runtime profile set-path`; the daemon prefers it over the
|
|
// PATH lookup in appendProfileRuntimes. Empty / absent means "resolve the
|
|
// profile's command_name on PATH" — the default behavior. The mapping is
|
|
// intentionally local-only (it is never sent to the server) because the
|
|
// path is a property of this machine, not of the shared profile.
|
|
ProfileCommandOverrides map[string]string `json:"profile_command_overrides,omitempty"`
|
|
}
|
|
|
|
// BackendOverrides holds per-backend configuration overrides. Each field is
|
|
// optional; nil means "no override for this backend". Keep new fields additive
|
|
// and tagged with `json:",omitempty"` so empty values do not change the saved
|
|
// config shape. Unknown-key preservation is a separate forward-compat concern:
|
|
// Go's encoding/json drops fields that are not represented in this struct on
|
|
// load/save round-trip (see TestCLIConfig_UnknownFieldsArePreserved).
|
|
type BackendOverrides struct {
|
|
OpenClaw *OpenClawOverride `json:"openclaw,omitempty"`
|
|
}
|
|
|
|
// OpenClawOverride configures the OpenClaw backend. All fields are optional;
|
|
// empty values fall through to the existing discovery path (PATH lookup for
|
|
// BinaryPath, default `~/.openclaw/` for StateDir).
|
|
//
|
|
// Resolution precedence (env beats config beats default, for back-compat):
|
|
//
|
|
// BinaryPath: MULTICA_OPENCLAW_PATH (env) > backends.openclaw.binary_path > PATH lookup
|
|
// StateDir: OPENCLAW_STATE_DIR (env) > backends.openclaw.state_dir > OpenClaw's built-in default (~/.openclaw)
|
|
//
|
|
// The StateDir env var here is OpenClaw's own OPENCLAW_STATE_DIR — NOT a new
|
|
// MULTICA_OPENCLAW_STATE_DIR. Rationale: OpenClaw already honors its own env
|
|
// var, the daemon already forwards inherited env to spawned children via
|
|
// `mergeEnv`, and a user who exports OPENCLAW_STATE_DIR in their shell
|
|
// already gets the right behavior with zero daemon changes today. This field
|
|
// is purely additive: when set, the daemon injects OPENCLAW_STATE_DIR=<value>
|
|
// into the spawned child's env unless the user already exported one upstream.
|
|
// (If a future use case needs daemon-namespaced isolation distinct from
|
|
// OpenClaw's own env, MULTICA_OPENCLAW_STATE_DIR can be layered on top
|
|
// without breaking this contract — see #3875 discussion.)
|
|
//
|
|
// Setting StateDir is the fix for the long-standing usability gap where
|
|
// users with non-default OpenClaw installations — multiple isolated
|
|
// profiles (dev/staging/prod, multiple accounts), containerized / CI
|
|
// deployments where ~/.openclaw isn't writable, or third-party desktop
|
|
// apps that bundle their own OpenClaw runtime — had to write a wrapper
|
|
// shell script to inject OPENCLAW_STATE_DIR + run `launchctl setenv`
|
|
// for GUI-launched daemons. With this field, those workarounds become
|
|
// unnecessary.
|
|
type OpenClawOverride struct {
|
|
BinaryPath string `json:"binary_path,omitempty"`
|
|
StateDir string `json:"state_dir,omitempty"`
|
|
}
|
|
|
|
// CLIConfigPath returns the default path for the CLI config file.
|
|
func CLIConfigPath() (string, error) {
|
|
return CLIConfigPathForProfile("")
|
|
}
|
|
|
|
// CLIConfigPathForProfile returns the config file path for the given profile.
|
|
// An empty profile returns the default path (~/.multica/config.json).
|
|
// A named profile returns ~/.multica/profiles/<name>/config.json.
|
|
func CLIConfigPathForProfile(profile string) (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve CLI config path: %w", err)
|
|
}
|
|
if profile == "" {
|
|
return filepath.Join(home, defaultCLIConfigPath), nil
|
|
}
|
|
return filepath.Join(home, ".multica", "profiles", profile, "config.json"), nil
|
|
}
|
|
|
|
// ProfileDir returns the base directory for a profile's state files (pid, log).
|
|
// An empty profile returns ~/.multica/. A named profile returns ~/.multica/profiles/<name>/.
|
|
func ProfileDir(profile string) (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve profile dir: %w", err)
|
|
}
|
|
if profile == "" {
|
|
return filepath.Join(home, ".multica"), nil
|
|
}
|
|
return filepath.Join(home, ".multica", "profiles", profile), nil
|
|
}
|
|
|
|
// LoadCLIConfig reads the CLI config from disk (default profile).
|
|
func LoadCLIConfig() (CLIConfig, error) {
|
|
return LoadCLIConfigForProfile("")
|
|
}
|
|
|
|
// LoadCLIConfigForProfile reads the CLI config for the given profile.
|
|
func LoadCLIConfigForProfile(profile string) (CLIConfig, error) {
|
|
path, err := CLIConfigPathForProfile(profile)
|
|
if err != nil {
|
|
return CLIConfig{}, err
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return CLIConfig{}, nil
|
|
}
|
|
return CLIConfig{}, fmt.Errorf("read CLI config: %w", err)
|
|
}
|
|
var cfg CLIConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return CLIConfig{}, fmt.Errorf("parse CLI config: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// SaveCLIConfig writes the CLI config to disk atomically (default profile).
|
|
func SaveCLIConfig(cfg CLIConfig) error {
|
|
return SaveCLIConfigForProfile(cfg, "")
|
|
}
|
|
|
|
// SaveCLIConfigForProfile writes the CLI config for the given profile.
|
|
func SaveCLIConfigForProfile(cfg CLIConfig, profile string) error {
|
|
path, err := CLIConfigPathForProfile(profile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create CLI config directory: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode CLI config: %w", err)
|
|
}
|
|
|
|
// Write to a temp file in the same directory, then rename for atomicity.
|
|
tmp, err := os.CreateTemp(dir, ".config-*.json.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp config file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(append(data, '\n')); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("write temp config file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("close temp config file: %w", err)
|
|
}
|
|
if err := os.Chmod(tmpPath, 0o600); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("chmod temp config file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("rename config file: %w", err)
|
|
}
|
|
return nil
|
|
}
|