mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 16:09:19 +02:00
Compare commits
1 Commits
chore/remo
...
feat/cli-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b15d6f6cf |
@@ -160,6 +160,12 @@ func init() {
|
||||
agentCreateCmd.Flags().String("description", "", "Agent description")
|
||||
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
|
||||
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
|
||||
// --from-template seeds the new agent from a curated template: imports the
|
||||
// template's skills into the workspace (find-or-create by name) and applies
|
||||
// the template's instructions. When set, --description/--instructions/
|
||||
// --custom-args/--custom-env/--runtime-config are ignored (the template
|
||||
// provides all the agent shape); --name and --runtime-id are still required.
|
||||
agentCreateCmd.Flags().String("from-template", "", "Template slug to seed the agent from (e.g. code-reviewer). Lists are available via GET /api/agent-templates.")
|
||||
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
|
||||
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
|
||||
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
||||
@@ -426,6 +432,14 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("--runtime-id is required")
|
||||
}
|
||||
|
||||
// --from-template short-circuits to the dedicated endpoint, which
|
||||
// fetches the template's skill URLs in parallel and creates the agent
|
||||
// + skill rows atomically. Skip the manual-create body building and
|
||||
// post the small template payload instead.
|
||||
if templateSlug, _ := cmd.Flags().GetString("from-template"); templateSlug != "" {
|
||||
return runAgentCreateFromTemplate(cmd, client, name, runtimeID, templateSlug)
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"runtime_id": runtimeID,
|
||||
@@ -492,6 +506,55 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAgentCreateFromTemplate posts to POST /api/agents/from-template. The
|
||||
// server fetches every referenced skill in parallel and writes everything in
|
||||
// a single transaction; a 422 here means at least one upstream URL was
|
||||
// unreachable, in which case the body carries the failing URLs so we can
|
||||
// surface them verbatim to the operator instead of a generic error.
|
||||
func runAgentCreateFromTemplate(cmd *cobra.Command, client *cli.APIClient, name, runtimeID, slug string) error {
|
||||
body := map[string]any{
|
||||
"template_slug": slug,
|
||||
"name": name,
|
||||
"runtime_id": runtimeID,
|
||||
}
|
||||
if cmd.Flags().Changed("model") {
|
||||
v, _ := cmd.Flags().GetString("model")
|
||||
body["model"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("max-concurrent-tasks") {
|
||||
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
|
||||
body["max_concurrent_tasks"] = v
|
||||
}
|
||||
|
||||
// 60s ceiling: templates fan out N HTTP fetches to GitHub, each ~200-500ms.
|
||||
// Matches the timeout used by `multica skill import` (cmd_skill.go).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/agents/from-template", body, &result); err != nil {
|
||||
return fmt.Errorf("create agent from template: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
agent, _ := result["agent"].(map[string]any)
|
||||
imported, _ := result["imported_skill_ids"].([]any)
|
||||
reused, _ := result["reused_skill_ids"].([]any)
|
||||
fmt.Printf("Agent created from template %q: %s (%s)\n", slug, strVal(agent, "name"), strVal(agent, "id"))
|
||||
if len(imported) > 0 || len(reused) > 0 {
|
||||
fmt.Printf(" Skills: %d imported, %d reused\n", len(imported), len(reused))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
|
||||
@@ -238,18 +238,6 @@ func TestAgentUpdateDoesNotExposeCustomEnvFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCreateDoesNotExposeFromTemplate guards against re-adding the
|
||||
// `--from-template` flag. It was an untaught, immature CLI surface that
|
||||
// short-circuited before body assembly — silently dropping sibling create
|
||||
// flags like --mcp-config / --custom-env — and was removed. The agent-template
|
||||
// backend API still exists but has no CLI surface; manual `agent create` is the
|
||||
// only supported CLI creation path.
|
||||
func TestAgentCreateDoesNotExposeFromTemplate(t *testing.T) {
|
||||
if agentCreateCmd.Flag("from-template") != nil {
|
||||
t.Error("agent create must NOT expose --from-template; it was removed as an untaught CLI surface that silently dropped sibling flags")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCustomEnvErrorSanitization guards against future changes
|
||||
// re-introducing %w wrapping of json.Unmarshal errors. Those errors
|
||||
// can surface short fragments of the input, which — for a flag that
|
||||
|
||||
@@ -19,30 +19,23 @@ go test ./internal/service -run TestBuiltinSkillsConformToTemplate
|
||||
| Contract | Line | Behavior | Safe check |
|
||||
|---|---|---|---|
|
||||
| Create flags: `name`, `description`, `instructions`, `runtime-id` | 159–162 | Registered create flags; `name`/`runtime-id` enforced in `runAgentCreate` | `multica agent create --help` |
|
||||
| `runtime-config`, `model`, `custom-args` flags | 163–165 | `model` help: "Prefer this over passing --model in --custom-args"; `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
|
||||
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 166–168 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
|
||||
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 169–171 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
|
||||
| MCP flags on `agent update` | 192–194 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
|
||||
| `runAgentCreate` builds body + `POST /api/agents` | 414 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 482) | read 414–489 |
|
||||
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model | 437–478 | `resolveCustomEnv` (455) and `resolveMcpConfig` (460) gate their secret channels; omitted flags are not sent | read 437–478 |
|
||||
| `runAgentUpdate` sends `mcp_config` | 550 | `resolveMcpConfig` adds `mcp_config` to the `PUT /api/agents/{id}` body (564); `custom_env` is intentionally not a flag here | read 495–565 |
|
||||
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1066, 1094 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 1066–1150 |
|
||||
| `agent skills set` = replace-all | 772 | `PUT /api/agents/{id}/skills` (790); `--skill-ids ''` clears all (779) | `multica agent skills set --help` |
|
||||
| `agent skills add` = additive | 797 | `POST /api/agents/{id}/skills/add` (818); requires ≥1 id (804, 808) | `multica agent skills add --help` |
|
||||
| `agent skills list` | 740 | reads bindings, no side effect | `multica agent skills list --help` |
|
||||
| `agent env get` | 874 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
|
||||
| `agent env set` | 909 | `PUT /api/agents/{id}/env` with full `custom_env` map (923, 929) | `multica agent env set --help` |
|
||||
| `runtime-config`, `model`, `custom-args` flags | 169–171 | `model` help: "Prefer this over passing --model in --custom-args"; `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
|
||||
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 172–174 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
|
||||
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 175–177 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
|
||||
| MCP flags on `agent update` | 198–200 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
|
||||
| `runAgentCreate` builds body + `POST /api/agents` | 420 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 496) | read 420–501 |
|
||||
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model | 443–487 | `resolveCustomEnv` (469) and `resolveMcpConfig` (474) gate their secret channels; omitted flags are not sent | read 443–487 |
|
||||
| `runAgentUpdate` sends `mcp_config` | 613 | `resolveMcpConfig` adds `mcp_config` to the `PUT /api/agents/{id}` body (627); `custom_env` is intentionally not a flag here | read 558–633 |
|
||||
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1129, 1157 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 1129–1215 |
|
||||
| `agent skills set` = replace-all | 835 | `PUT /api/agents/{id}/skills` (853); `--skill-ids ''` clears all (842) | `multica agent skills set --help` |
|
||||
| `agent skills add` = additive | 860 | `POST /api/agents/{id}/skills/add` (881); requires ≥1 id (867, 871) | `multica agent skills add --help` |
|
||||
| `agent skills list` | 803 | reads bindings, no side effect | `multica agent skills list --help` |
|
||||
| `agent env get` | 937 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
|
||||
| `agent env set` | 972 | `PUT /api/agents/{id}/env` with full `custom_env` map (986, 992) | `multica agent env set --help` |
|
||||
|
||||
Note: the CLI no longer exposes `--from-template`. The agent-template backend
|
||||
still exists (registry `server/internal/agenttmpl/`, handler `agent_template.go`,
|
||||
routes `GET /api/agent-templates` and `POST /api/agents/from-template`, plus the
|
||||
`packages/core` client/query wrappers) but is currently orphaned plumbing with no
|
||||
live caller: the removed CLI flag was its only non-test consumer, and onboarding
|
||||
does NOT use it — `packages/views/onboarding/steps/step-agent.tsx` builds four
|
||||
hardcoded local presets (i18n-resolved) and creates via plain `POST /api/agents`
|
||||
(`createAgent`), never `POST /api/agents/from-template`. Do not treat the template
|
||||
API as a supported agent-creation path. This skill teaches manual `agent create`
|
||||
only.
|
||||
Note: `--from-template` exists at line 168 and short-circuits to
|
||||
`runAgentCreateFromTemplate` (line 514). It is intentionally NOT taught — the
|
||||
template path is immature and out of scope for this skill.
|
||||
|
||||
## Create handler — `server/internal/handler/agent.go`
|
||||
|
||||
|
||||
@@ -590,36 +590,10 @@ func mergeEnv(base []string, extra map[string]string) []string {
|
||||
return env
|
||||
}
|
||||
|
||||
// isFilteredChildEnvKey reports whether an inherited env var is an internal
|
||||
// Claude Code runtime/session marker that must NOT leak into the spawned child
|
||||
// (otherwise the child mistakes itself for a nested or resumed session, or
|
||||
// inherits the parent's exec path / transport).
|
||||
//
|
||||
// It must NOT strip the user-facing CLAUDE_CODE_* configuration namespace
|
||||
// (CLAUDE_CODE_GIT_BASH_PATH, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX,
|
||||
// CLAUDE_CODE_MAX_OUTPUT_TOKENS, CLAUDE_CODE_TMPDIR, ...): users set those
|
||||
// deliberately and the child needs them. Blanket-stripping the whole prefix is
|
||||
// what broke Windows — CLAUDE_CODE_GIT_BASH_PATH was silently removed, so Claude
|
||||
// Code could not find bash.exe and exited immediately. Strip internal markers by
|
||||
// exact name and let every other CLAUDE_CODE_* var through.
|
||||
//
|
||||
// The denylist holds only undocumented, per-process runtime markers. Anything in
|
||||
// the public env-vars reference (https://code.claude.com/docs/en/env-vars) is
|
||||
// user config and stays out of this list — including CLAUDE_CODE_TMPDIR, a
|
||||
// documented temp-dir override under which Claude Code creates its own
|
||||
// per-session subdir, so inheriting it is harmless.
|
||||
func isFilteredChildEnvKey(key string) bool {
|
||||
switch key {
|
||||
case "CLAUDECODE", // "1" when running inside Claude Code
|
||||
"CLAUDE_CODE_ENTRYPOINT", // entrypoint marker (cli/sdk-cli/...)
|
||||
"CLAUDE_CODE_EXECPATH", // path to the running CLI binary
|
||||
"CLAUDE_CODE_SESSION_ID", // per-session identifier
|
||||
"CLAUDE_CODE_SSE_PORT": // IDE-extension transport port
|
||||
return true
|
||||
}
|
||||
// CLAUDECODE_* (no underscore between CLAUDE and CODE) is wholly internal;
|
||||
// keep stripping it. The user-facing config namespace is CLAUDE_CODE_*.
|
||||
return strings.HasPrefix(key, "CLAUDECODE_")
|
||||
return key == "CLAUDECODE" ||
|
||||
strings.HasPrefix(key, "CLAUDECODE_") ||
|
||||
strings.HasPrefix(key, "CLAUDE_CODE_")
|
||||
}
|
||||
|
||||
// blockedArgMode specifies whether a blocked arg takes a value or is standalone.
|
||||
|
||||
@@ -417,29 +417,12 @@ func TestMergeEnvFiltersClaudeCodeVars(t *testing.T) {
|
||||
"PATH=/usr/bin",
|
||||
"CLAUDECODE=1",
|
||||
"CLAUDE_CODE_ENTRYPOINT=cli",
|
||||
"CLAUDE_CODE_EXECPATH=/opt/claude",
|
||||
"CLAUDE_CODE_SESSION_ID=abc123",
|
||||
"CLAUDE_CODE_SSE_PORT=9999",
|
||||
"CLAUDECODEX=keep-me",
|
||||
"CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe",
|
||||
"CLAUDE_CODE_USE_BEDROCK=1",
|
||||
"CLAUDE_CODE_TMPDIR=/custom/tmp",
|
||||
}, map[string]string{"FOO": "bar"})
|
||||
|
||||
// Internal runtime/session markers must be stripped so the child does not
|
||||
// inherit the parent's identity or transport.
|
||||
filteredOut := []string{
|
||||
"CLAUDECODE=1",
|
||||
"CLAUDE_CODE_ENTRYPOINT=cli",
|
||||
"CLAUDE_CODE_EXECPATH=/opt/claude",
|
||||
"CLAUDE_CODE_SESSION_ID=abc123",
|
||||
"CLAUDE_CODE_SSE_PORT=9999",
|
||||
}
|
||||
for _, entry := range env {
|
||||
for _, banned := range filteredOut {
|
||||
if entry == banned {
|
||||
t.Fatalf("expected internal Claude Code marker %q to be filtered, got %v", banned, env)
|
||||
}
|
||||
if entry == "CLAUDECODE=1" || entry == "CLAUDE_CODE_ENTRYPOINT=cli" {
|
||||
t.Fatalf("expected CLAUDECODE vars to be filtered, got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,19 +437,6 @@ func TestMergeEnvFiltersClaudeCodeVars(t *testing.T) {
|
||||
if !found["CLAUDECODEX=keep-me"] {
|
||||
t.Fatalf("expected unrelated env vars to be preserved, got %v", env)
|
||||
}
|
||||
// User-facing CLAUDE_CODE_* config must reach the child — stripping
|
||||
// CLAUDE_CODE_GIT_BASH_PATH is what broke Claude Code on Windows (#3671).
|
||||
if !found["CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe"] {
|
||||
t.Fatalf("expected CLAUDE_CODE_GIT_BASH_PATH to be preserved, got %v", env)
|
||||
}
|
||||
if !found["CLAUDE_CODE_USE_BEDROCK=1"] {
|
||||
t.Fatalf("expected CLAUDE_CODE_USE_BEDROCK to be preserved, got %v", env)
|
||||
}
|
||||
// CLAUDE_CODE_TMPDIR is a documented user-configurable temp-dir override, not
|
||||
// an internal per-session marker, so it must reach the child.
|
||||
if !found["CLAUDE_CODE_TMPDIR=/custom/tmp"] {
|
||||
t.Fatalf("expected CLAUDE_CODE_TMPDIR to be preserved, got %v", env)
|
||||
}
|
||||
if !found["FOO=bar"] {
|
||||
t.Fatalf("expected extra env var to be appended, got %v", env)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user