@swarmclawai/swarmclaw 1.5.31 → 1.5.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +5 -0
  2. package/bin/swarmclaw.js +35 -1
  3. package/package.json +2 -2
  4. package/scripts/run-next-build.mjs +87 -2
  5. package/src/app/api/agents/[id]/route.ts +8 -0
  6. package/src/app/api/agents/agents-route.test.ts +114 -0
  7. package/src/app/api/agents/route.ts +10 -0
  8. package/src/app/api/connectors/route.ts +25 -13
  9. package/src/app/api/credentials/[id]/route.ts +8 -1
  10. package/src/app/api/schedules/[id]/route.ts +8 -0
  11. package/src/app/api/secrets/[id]/route.ts +10 -0
  12. package/src/app/api/setup/check-provider/route.ts +45 -0
  13. package/src/app/api/setup/doctor/route.ts +5 -0
  14. package/src/cli/binary.test.js +11 -0
  15. package/src/cli/index.js +4 -4
  16. package/src/cli/index.test.js +5 -2
  17. package/src/cli/index.ts +1 -1
  18. package/src/components/agents/agent-sheet.tsx +16 -4
  19. package/src/components/agents/inspector-panel.tsx +5 -0
  20. package/src/components/auth/setup-wizard/step-agents.tsx +19 -1
  21. package/src/components/chat/activity-moment.tsx +3 -0
  22. package/src/components/chat/chat-header.tsx +23 -2
  23. package/src/components/chat/tool-call-bubble.tsx +20 -0
  24. package/src/hooks/setup-done-detection.test.ts +4 -2
  25. package/src/hooks/setup-done-detection.ts +1 -1
  26. package/src/lib/orchestrator-config.ts +5 -0
  27. package/src/lib/provider-sets.ts +4 -4
  28. package/src/lib/providers/acp-client.ts +116 -0
  29. package/src/lib/providers/cli-utils.test.ts +9 -1
  30. package/src/lib/providers/cli-utils.ts +89 -4
  31. package/src/lib/providers/cursor-cli.ts +172 -0
  32. package/src/lib/providers/goose.ts +149 -0
  33. package/src/lib/providers/index.ts +29 -1
  34. package/src/lib/providers/qwen-code-cli.ts +152 -0
  35. package/src/lib/server/agents/agent-availability.ts +2 -2
  36. package/src/lib/server/agents/agent-thread-session.ts +8 -0
  37. package/src/lib/server/agents/task-session.ts +8 -0
  38. package/src/lib/server/capability-router.ts +8 -2
  39. package/src/lib/server/chat-execution/chat-execution-utils.ts +13 -0
  40. package/src/lib/server/chat-execution/chat-turn-finalization.ts +8 -0
  41. package/src/lib/server/chat-execution/chat-turn-preparation.ts +5 -1
  42. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +3 -0
  43. package/src/lib/server/chat-execution/iteration-timers.test.ts +84 -0
  44. package/src/lib/server/chat-execution/iteration-timers.ts +18 -1
  45. package/src/lib/server/chat-execution/prompt-sections.ts +3 -1
  46. package/src/lib/server/chat-execution/stream-agent-chat.ts +5 -0
  47. package/src/lib/server/chatrooms/chatroom-helpers.ts +13 -0
  48. package/src/lib/server/chats/chat-session-service.ts +18 -0
  49. package/src/lib/server/connectors/session.ts +8 -0
  50. package/src/lib/server/context-manager.ts +5 -0
  51. package/src/lib/server/provider-health.ts +13 -3
  52. package/src/lib/server/provider-model-discovery.test.ts +8 -0
  53. package/src/lib/server/provider-model-discovery.ts +1 -1
  54. package/src/lib/server/runtime/daemon-state/core.ts +2 -2
  55. package/src/lib/server/runtime/queue/core.ts +30 -4
  56. package/src/lib/server/runtime/session-run-manager/enqueue.ts +1 -1
  57. package/src/lib/server/session-reset-policy.test.ts +16 -0
  58. package/src/lib/server/session-reset-policy.ts +9 -1
  59. package/src/lib/server/session-tools/context.ts +2 -2
  60. package/src/lib/server/session-tools/delegate.ts +334 -14
  61. package/src/lib/server/session-tools/index.ts +5 -2
  62. package/src/lib/server/session-tools/session-info.ts +4 -1
  63. package/src/lib/server/storage-auth-docker.test.ts +244 -0
  64. package/src/lib/server/storage-auth.test.ts +150 -0
  65. package/src/lib/server/storage-auth.ts +57 -22
  66. package/src/lib/server/storage-normalization.ts +19 -0
  67. package/src/lib/server/storage.ts +3 -0
  68. package/src/lib/server/tasks/task-resume.ts +23 -1
  69. package/src/lib/server/tool-aliases.ts +1 -1
  70. package/src/lib/server/tool-capability-policy.ts +4 -1
  71. package/src/lib/setup-defaults.test.ts +6 -0
  72. package/src/lib/setup-defaults.ts +146 -0
  73. package/src/lib/tool-definitions.ts +1 -1
  74. package/src/types/misc.ts +4 -1
  75. package/src/types/provider.ts +1 -1
  76. package/src/types/session.ts +9 -0
package/README.md CHANGED
@@ -371,6 +371,11 @@ Operational docs: https://swarmclaw.ai/docs/observability
371
371
 
372
372
  ## Releases
373
373
 
374
+ ### v1.5.33 Highlights
375
+
376
+ - **CLI global flag compatibility**: legacy-routed commands now honor the documented `--access-key` and `--base-url` aliases even when they appear after the subcommand, so authenticated CLI automation works the same across binary entry points.
377
+ - **Docker build memory hardening**: production Next.js builds now size `--max-old-space-size` from the detected container/cgroup memory limit, with `SWARMCLAW_BUILD_MAX_OLD_SPACE_SIZE_MB` available as an explicit override for constrained Docker Desktop and CI environments.
378
+
374
379
  ### v1.5.31 Highlights
375
380
 
376
381
  - **Fix Docker first-run crash**: resolved `EISDIR: illegal operation on a directory, read` error when running `docker compose up` without a pre-existing `.env.local` file. Docker was creating a directory mount instead of a file, which crashed Next.js on startup. Replaced the file bind mount with `env_file` directive using `required: false`.
package/bin/swarmclaw.js CHANGED
@@ -19,6 +19,11 @@ const TS_CLI_ACTIONS = Object.freeze({
19
19
  webhooks: new Set(['list', 'get', 'create', 'update', 'delete', 'trigger']),
20
20
  })
21
21
 
22
+ const LEGACY_TS_CLI_ALIAS_MAP = Object.freeze({
23
+ '--base-url': '--url',
24
+ '--access-key': '--key',
25
+ })
26
+
22
27
  function shouldUseLegacyTsCli(argv) {
23
28
  const group = argv[0]
24
29
  const action = argv[1]
@@ -62,9 +67,37 @@ function buildLegacyTsCliArgs(cliPath, argv, options = {}) {
62
67
  return null
63
68
  }
64
69
 
70
+ function normalizeLegacyTsCliArgv(argv) {
71
+ const normalized = []
72
+
73
+ for (const token of argv) {
74
+ if (!token.startsWith('--')) {
75
+ normalized.push(token)
76
+ continue
77
+ }
78
+
79
+ const eqIndex = token.indexOf('=')
80
+ const flag = eqIndex > -1 ? token.slice(0, eqIndex) : token
81
+ const mappedFlag = LEGACY_TS_CLI_ALIAS_MAP[flag]
82
+
83
+ if (!mappedFlag) {
84
+ normalized.push(token)
85
+ continue
86
+ }
87
+
88
+ if (eqIndex > -1) {
89
+ normalized.push(`${mappedFlag}=${token.slice(eqIndex + 1)}`)
90
+ } else {
91
+ normalized.push(mappedFlag)
92
+ }
93
+ }
94
+
95
+ return normalized
96
+ }
97
+
65
98
  function runLegacyTsCli(argv) {
66
99
  const cliPath = path.join(__dirname, '..', 'src', 'cli', 'index.ts')
67
- const args = buildLegacyTsCliArgs(cliPath, argv)
100
+ const args = buildLegacyTsCliArgs(cliPath, normalizeLegacyTsCliArgv(argv))
68
101
  const env = normalizeLegacyCliEnv(process.env)
69
102
  if (!args) {
70
103
  process.stderr.write('Legacy CLI commands require Node 22.6+ or an available local tsx runtime.\n')
@@ -237,6 +270,7 @@ if (require.main === module) {
237
270
  module.exports = {
238
271
  buildLegacyTsCliArgs,
239
272
  hasTsxRuntime,
273
+ normalizeLegacyTsCliArgv,
240
274
  TS_CLI_ACTIONS,
241
275
  normalizeLegacyCliEnv,
242
276
  printPackageVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.31",
3
+ "version": "1.5.33",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -72,7 +72,7 @@
72
72
  "lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
73
73
  "cli": "node ./bin/swarmclaw.js",
74
74
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
75
- "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts",
75
+ "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
76
76
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
77
77
  "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
78
78
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from 'node:fs'
4
+ import os from 'node:os'
4
5
  import path from 'node:path'
5
6
  import { spawnSync } from 'node:child_process'
6
7
  import { createRequire } from 'node:module'
@@ -11,6 +12,17 @@ import { ensureBuildBootstrapPaths } from './build-bootstrap-env.mjs'
11
12
  const require = createRequire(import.meta.url)
12
13
 
13
14
  export const DEFAULT_MAX_OLD_SPACE_SIZE_MB = '8192'
15
+ export const MIN_MAX_OLD_SPACE_SIZE_MB = 1024
16
+ export const FALLBACK_MIN_MAX_OLD_SPACE_SIZE_MB = 512
17
+ export const RESERVED_BUILD_MEMORY_MB = 768
18
+ export const MAX_OLD_SPACE_RATIO = 0.75
19
+ export const LOW_MEMORY_RATIO = 0.6
20
+ export const BUILD_MAX_OLD_SPACE_SIZE_ENV = 'SWARMCLAW_BUILD_MAX_OLD_SPACE_SIZE_MB'
21
+ export const CGROUP_MEMORY_LIMIT_PATHS = [
22
+ '/sys/fs/cgroup/memory.max',
23
+ '/sys/fs/cgroup/memory/memory.limit_in_bytes',
24
+ ]
25
+ export const UNBOUNDED_MEMORY_LIMIT_BYTES = 1n << 60n
14
26
  export const TRACE_COPY_WARNING = 'Failed to copy traced files'
15
27
  export const NEXT_STANDALONE_METADATA_RELATIVE_DIR = path.join(
16
28
  'node_modules',
@@ -24,6 +36,74 @@ export const REQUIRED_NEXT_METADATA_FILES = [
24
36
  'is-metadata-route.js',
25
37
  ]
26
38
 
39
+ function parsePositiveInteger(value) {
40
+ const parsed = Number.parseInt(String(value ?? '').trim(), 10)
41
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
42
+ }
43
+
44
+ export function readCgroupMemoryLimitBytes(
45
+ paths = CGROUP_MEMORY_LIMIT_PATHS,
46
+ existsSync = fs.existsSync,
47
+ readFileSync = fs.readFileSync,
48
+ ) {
49
+ for (const filePath of paths) {
50
+ if (!existsSync(filePath)) continue
51
+
52
+ let raw = ''
53
+ try {
54
+ raw = String(readFileSync(filePath, 'utf8')).trim()
55
+ } catch {
56
+ continue
57
+ }
58
+
59
+ if (!raw || raw === 'max') continue
60
+
61
+ try {
62
+ const bytes = BigInt(raw)
63
+ if (bytes <= 0n || bytes >= UNBOUNDED_MEMORY_LIMIT_BYTES) continue
64
+ return Number(bytes)
65
+ } catch {
66
+ continue
67
+ }
68
+ }
69
+
70
+ return null
71
+ }
72
+
73
+ export function deriveMaxOldSpaceSizeMb(memoryLimitBytes, defaultMaxOldSpaceSizeMb = DEFAULT_MAX_OLD_SPACE_SIZE_MB) {
74
+ const defaultMb = parsePositiveInteger(defaultMaxOldSpaceSizeMb) ?? Number.parseInt(DEFAULT_MAX_OLD_SPACE_SIZE_MB, 10)
75
+ const limitMb = Math.floor(Number(memoryLimitBytes) / (1024 * 1024))
76
+ if (!Number.isFinite(limitMb) || limitMb <= 0) return String(defaultMb)
77
+
78
+ const constrainedCandidate = Math.min(
79
+ defaultMb,
80
+ limitMb - RESERVED_BUILD_MEMORY_MB,
81
+ Math.floor(limitMb * MAX_OLD_SPACE_RATIO),
82
+ )
83
+ if (constrainedCandidate >= MIN_MAX_OLD_SPACE_SIZE_MB) {
84
+ return String(constrainedCandidate)
85
+ }
86
+
87
+ return String(Math.max(
88
+ FALLBACK_MIN_MAX_OLD_SPACE_SIZE_MB,
89
+ Math.min(defaultMb, Math.floor(limitMb * LOW_MEMORY_RATIO)),
90
+ ))
91
+ }
92
+
93
+ export function resolveNextBuildMaxOldSpaceSizeMb(
94
+ env = process.env,
95
+ options = {},
96
+ ) {
97
+ const explicit = parsePositiveInteger(env[BUILD_MAX_OLD_SPACE_SIZE_ENV])
98
+ if (explicit) return String(explicit)
99
+
100
+ const readLimitBytes = options.readCgroupMemoryLimitBytes ?? readCgroupMemoryLimitBytes
101
+ const totalMemFn = options.totalMem ?? os.totalmem
102
+ const memoryLimitBytes = readLimitBytes() ?? totalMemFn()
103
+
104
+ return deriveMaxOldSpaceSizeMb(memoryLimitBytes, DEFAULT_MAX_OLD_SPACE_SIZE_MB)
105
+ }
106
+
27
107
  export function mergeNodeOptions(nodeOptions = '', maxOldSpaceSizeMb = DEFAULT_MAX_OLD_SPACE_SIZE_MB) {
28
108
  const trimmed = nodeOptions.trim()
29
109
  if (/(^|\s)--max-old-space-size(?:=|\s|$)/.test(trimmed)) return trimmed
@@ -120,12 +200,17 @@ export function repairStandaloneNextMetadata(cwd = process.cwd()) {
120
200
  return true
121
201
  }
122
202
 
123
- export function runNextBuild(args = process.argv.slice(2), env = process.env, cwd = process.cwd()) {
203
+ export function runNextBuild(
204
+ args = process.argv.slice(2),
205
+ env = process.env,
206
+ cwd = process.cwd(),
207
+ maxOldSpaceSizeMb = resolveNextBuildMaxOldSpaceSizeMb(env),
208
+ ) {
124
209
  const nextBin = require.resolve('next/dist/bin/next')
125
210
  return spawnSync(process.execPath, [nextBin, 'build', '--webpack', ...args], {
126
211
  stdio: 'pipe',
127
212
  encoding: 'utf-8',
128
- env: buildNextBuildEnv(env, DEFAULT_MAX_OLD_SPACE_SIZE_MB, cwd),
213
+ env: buildNextBuildEnv(env, maxOldSpaceSizeMb, cwd),
129
214
  cwd,
130
215
  })
131
216
  }
@@ -1,9 +1,17 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { notFound } from '@/lib/server/collection-helpers'
3
3
  import { trashAgent, updateAgent } from '@/lib/server/agents/agent-service'
4
+ import { loadAgent } from '@/lib/server/agents/agent-repository'
4
5
  import { notify } from '@/lib/server/ws-hub'
5
6
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
7
 
8
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params
10
+ const agent = loadAgent(id)
11
+ if (!agent) return notFound()
12
+ return NextResponse.json(agent)
13
+ }
14
+
7
15
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
8
16
  const { id } = await params
9
17
  const { data: body, error } = await safeParseBody(req)
@@ -0,0 +1,114 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+
4
+ // Disable daemon autostart during tests
5
+ process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
6
+
7
+ import { GET as getAgent } from './[id]/route'
8
+ import { POST as createAgent } from './route'
9
+ import { loadAgents, saveAgents } from '@/lib/server/storage'
10
+
11
+ const originalAgents = loadAgents()
12
+
13
+ function routeParams(id: string) {
14
+ return { params: Promise.resolve({ id }) }
15
+ }
16
+
17
+ function seedAgent(id: string, overrides: Record<string, unknown> = {}) {
18
+ const agents = loadAgents()
19
+ const now = Date.now()
20
+ agents[id] = {
21
+ id,
22
+ name: 'Test Agent',
23
+ description: 'Route test',
24
+ systemPrompt: '',
25
+ provider: 'ollama',
26
+ model: 'qwen3.5',
27
+ credentialId: null,
28
+ fallbackCredentialIds: [],
29
+ apiEndpoint: null,
30
+ gatewayProfileId: null,
31
+ extensions: [],
32
+ createdAt: now,
33
+ updatedAt: now,
34
+ ...overrides,
35
+ }
36
+ saveAgents(agents)
37
+ }
38
+
39
+ afterEach(() => {
40
+ saveAgents(originalAgents)
41
+ })
42
+
43
+ // --- GET /api/agents/:id ---
44
+
45
+ test('GET /api/agents/:id returns the agent when it exists', async () => {
46
+ seedAgent('agent-get-test', { name: 'GetMe' })
47
+
48
+ const response = await getAgent(
49
+ new Request('http://local/api/agents/agent-get-test'),
50
+ routeParams('agent-get-test'),
51
+ )
52
+
53
+ assert.equal(response.status, 200)
54
+ const body = await response.json()
55
+ assert.equal(body.id, 'agent-get-test')
56
+ assert.equal(body.name, 'GetMe')
57
+ })
58
+
59
+ test('GET /api/agents/:id returns 404 for a non-existent agent', async () => {
60
+ const response = await getAgent(
61
+ new Request('http://local/api/agents/does-not-exist'),
62
+ routeParams('does-not-exist'),
63
+ )
64
+
65
+ assert.equal(response.status, 404)
66
+ const body = await response.json()
67
+ assert.equal(body.error, 'Not found')
68
+ })
69
+
70
+ // --- POST /api/agents (provider validation) ---
71
+
72
+ test('POST /api/agents rejects an unknown provider with a 400', async () => {
73
+ const response = await createAgent(new Request('http://local/api/agents', {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ name: 'Bad Provider Agent', provider: 'nonexistent_provider', model: 'x' }),
77
+ }))
78
+
79
+ assert.equal(response.status, 400)
80
+ const body = await response.json()
81
+ assert.equal(body.error, 'Validation failed')
82
+ assert.ok(body.issues.some((i: { path: string; message: string }) => i.path === 'provider'))
83
+ })
84
+
85
+ test('POST /api/agents accepts a valid provider and creates the agent', async () => {
86
+ const response = await createAgent(new Request('http://local/api/agents', {
87
+ method: 'POST',
88
+ headers: { 'content-type': 'application/json' },
89
+ body: JSON.stringify({ name: 'Good Agent', provider: 'ollama', model: 'qwen3.5' }),
90
+ }))
91
+
92
+ assert.equal(response.status, 200)
93
+ const body = await response.json()
94
+ assert.equal(body.name, 'Good Agent')
95
+ assert.equal(body.provider, 'ollama')
96
+ assert.ok(body.id)
97
+
98
+ // Clean up
99
+ const agents = loadAgents()
100
+ delete agents[body.id]
101
+ saveAgents(agents)
102
+ })
103
+
104
+ test('POST /api/agents rejects missing required fields with a 400', async () => {
105
+ const response = await createAgent(new Request('http://local/api/agents', {
106
+ method: 'POST',
107
+ headers: { 'content-type': 'application/json' },
108
+ body: JSON.stringify({}),
109
+ }))
110
+
111
+ assert.equal(response.status, 400)
112
+ const body = await response.json()
113
+ assert.equal(body.error, 'Validation failed')
114
+ })
@@ -3,6 +3,7 @@ import { perf } from '@/lib/server/runtime/perf'
3
3
  import { listAgentsForApi, createAgent } from '@/lib/server/agents/agent-service'
4
4
  import { AgentCreateSchema, formatZodError } from '@/lib/validation/schemas'
5
5
  import { ensureDaemonProcessRunning } from '@/lib/server/daemon/controller'
6
+ import { getProvider } from '@/lib/providers'
6
7
  import { z } from 'zod'
7
8
  import { safeParseBody } from '@/lib/server/safe-parse-body'
8
9
  import { loadSettings } from '@/lib/server/storage'
@@ -40,6 +41,15 @@ export async function POST(req: Request) {
40
41
  }
41
42
  const body = parsed.data as unknown as Record<string, unknown>
42
43
 
44
+ // Validate provider exists
45
+ const providerId = String(body.provider || '')
46
+ if (providerId && !getProvider(providerId)) {
47
+ return NextResponse.json(
48
+ { error: 'Validation failed', issues: [{ path: 'provider', message: `Unknown provider: "${providerId}"` }] },
49
+ { status: 400 },
50
+ )
51
+ }
52
+
43
53
  // Check approval policy — if enabled, create an approval request instead of the agent
44
54
  const settings = loadSettings()
45
55
  if (settings.approvalPolicies?.requireApprovalForAgentCreate) {
@@ -13,21 +13,33 @@ import { ensureDaemonProcessRunning } from '@/lib/server/daemon/controller'
13
13
  export const dynamic = 'force-dynamic'
14
14
 
15
15
  export async function GET() {
16
- const endPerf = perf.start('api', 'GET /api/connectors')
17
- const connectors = await listConnectorsWithRuntime()
18
- endPerf({ count: Object.keys(connectors).length })
19
- return NextResponse.json(connectors)
16
+ try {
17
+ const endPerf = perf.start('api', 'GET /api/connectors')
18
+ const connectors = await listConnectorsWithRuntime()
19
+ endPerf({ count: Object.keys(connectors).length })
20
+ return NextResponse.json(connectors)
21
+ } catch (err) {
22
+ return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 })
23
+ }
20
24
  }
21
25
 
22
26
  export async function POST(req: Request) {
23
- await ensureDaemonProcessRunning('api/connectors:post')
24
- const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
25
- if (error) return error
26
- const parsed = ConnectorCreateSchema.safeParse(raw)
27
- if (!parsed.success) {
28
- return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
27
+ try {
28
+ await ensureDaemonProcessRunning('api/connectors:post')
29
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
30
+ if (error) return error
31
+ const parsed = ConnectorCreateSchema.safeParse(raw)
32
+ if (!parsed.success) {
33
+ return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
34
+ }
35
+ const connector = createConnector(parsed.data as unknown as Record<string, unknown>)
36
+ try {
37
+ await autoStartConnectorIfNeeded(connector, parsed.data as unknown as Record<string, unknown>)
38
+ } catch {
39
+ // Auto-start failure is non-fatal — the connector is still saved.
40
+ }
41
+ return NextResponse.json(await getConnectorWithRuntime(connector.id) || connector)
42
+ } catch (err) {
43
+ return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 })
29
44
  }
30
- const connector = createConnector(parsed.data as unknown as Record<string, unknown>)
31
- await autoStartConnectorIfNeeded(connector, parsed.data as unknown as Record<string, unknown>)
32
- return NextResponse.json(await getConnectorWithRuntime(connector.id) || connector)
33
45
  }
@@ -1,11 +1,18 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { deleteCredentialRecord } from '@/lib/server/credentials/credential-service'
2
+ import { deleteCredentialRecord, getCredentialSummary } from '@/lib/server/credentials/credential-service'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
4
  import { log } from '@/lib/server/logger'
5
5
  import { logActivity } from '@/lib/server/activity/activity-log'
6
6
 
7
7
  const TAG = 'api-credentials'
8
8
 
9
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params
11
+ const summary = getCredentialSummary(id)
12
+ if (!summary) return notFound()
13
+ return NextResponse.json(summary)
14
+ }
15
+
9
16
  export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10
17
  const { id: credId } = await params
11
18
  if (!deleteCredentialRecord(credId)) {
@@ -1,11 +1,19 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { notFound } from '@/lib/server/collection-helpers'
3
3
  import { safeParseBody } from '@/lib/server/safe-parse-body'
4
+ import { loadSchedule } from '@/lib/server/schedules/schedule-repository'
4
5
  import {
5
6
  deleteScheduleFromRoute,
6
7
  updateScheduleFromRoute,
7
8
  } from '@/lib/server/schedules/schedule-route-service'
8
9
 
10
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
11
+ const { id } = await params
12
+ const schedule = loadSchedule(id)
13
+ if (!schedule) return notFound()
14
+ return NextResponse.json(schedule)
15
+ }
16
+
9
17
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
10
18
  const { id } = await params
11
19
  const { data: body, error } = await safeParseBody(req)
@@ -6,6 +6,16 @@ import { safeParseBody } from '@/lib/server/safe-parse-body'
6
6
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
7
  const ops: CollectionOps<any> = { load: loadSecrets, save: saveSecrets }
8
8
 
9
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params
11
+ const secrets = loadSecrets()
12
+ const secret = secrets[id]
13
+ if (!secret) return notFound()
14
+ // Never expose the encrypted value
15
+ const { encryptedValue, ...safe } = secret as Record<string, unknown>
16
+ return NextResponse.json(safe)
17
+ }
18
+
9
19
  export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10
20
  const { id } = await params
11
21
  if (!deleteItem(ops, id)) return notFound()
@@ -1,11 +1,20 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { loadCredentials, decryptKey } from '@/lib/server/storage'
3
3
  import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/openclaw'
4
+ import { buildCliEnv, probeCliAuth, resolveCliBinary } from '@/lib/providers/cli-utils'
4
5
  import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
5
6
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
6
7
  import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
7
8
 
8
9
  type SetupProvider =
10
+ | 'claude-cli'
11
+ | 'codex-cli'
12
+ | 'opencode-cli'
13
+ | 'gemini-cli'
14
+ | 'copilot-cli'
15
+ | 'cursor-cli'
16
+ | 'qwen-code-cli'
17
+ | 'goose'
9
18
  | 'openai'
10
19
  | 'openrouter'
11
20
  | 'anthropic'
@@ -22,6 +31,8 @@ type SetupProvider =
22
31
  | 'openclaw'
23
32
  | 'hermes'
24
33
 
34
+ type CliSetupProvider = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'gemini-cli' | 'copilot-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose'
35
+
25
36
  interface SetupCheckBody {
26
37
  provider?: string
27
38
  apiKey?: string
@@ -266,6 +277,34 @@ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok:
266
277
  return { ok: true, message: 'Connected to OpenClaw gateway.', normalizedEndpoint, deviceId, recommendedModel }
267
278
  }
268
279
 
280
+ function checkCliProvider(provider: CliSetupProvider): { ok: boolean; message: string } {
281
+ const env = buildCliEnv()
282
+ const config = {
283
+ 'claude-cli': { binary: 'claude', backend: 'claude' as const, label: 'Claude Code CLI' },
284
+ 'codex-cli': { binary: 'codex', backend: 'codex' as const, label: 'OpenAI Codex CLI' },
285
+ 'opencode-cli': { binary: 'opencode', backend: 'opencode' as const, label: 'OpenCode CLI' },
286
+ 'gemini-cli': { binary: 'gemini', backend: 'gemini' as const, label: 'Gemini CLI' },
287
+ 'copilot-cli': { binary: 'copilot', backend: 'copilot' as const, label: 'GitHub Copilot CLI' },
288
+ 'cursor-cli': { binary: 'cursor-agent', backend: 'cursor' as const, label: 'Cursor Agent CLI' },
289
+ 'qwen-code-cli': { binary: 'qwen', backend: 'qwen' as const, label: 'Qwen Code CLI' },
290
+ goose: { binary: 'goose', backend: 'goose' as const, label: 'Goose CLI' },
291
+ }[provider]
292
+
293
+ if (!config) return { ok: false, message: 'Unknown CLI provider.' }
294
+ const binary = resolveCliBinary(config.binary)
295
+ if (!binary) {
296
+ return {
297
+ ok: false,
298
+ message: `${config.label} is not installed. Install \`${config.binary}\` and ensure it is on your PATH.`,
299
+ }
300
+ }
301
+ const auth = probeCliAuth(binary, config.backend, env, process.cwd())
302
+ if (!auth.authenticated) {
303
+ return { ok: false, message: auth.errorMessage || `${config.label} is not configured.` }
304
+ }
305
+ return { ok: true, message: `${config.label} is installed and ready.` }
306
+ }
307
+
269
308
  export async function POST(req: Request) {
270
309
  const body = parseBody(await req.json().catch(() => ({})))
271
310
  const provider = clean(body.provider) as SetupProvider
@@ -273,6 +312,7 @@ export async function POST(req: Request) {
273
312
  const credentialId = clean(body.credentialId)
274
313
  const endpoint = clean(body.endpoint)
275
314
  const model = clean(body.model)
315
+ const CLI_PROVIDERS = new Set<CliSetupProvider>(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose'])
276
316
 
277
317
  // Resolve credentialId to an API key if no raw key was provided
278
318
  if (!apiKey && credentialId) {
@@ -287,6 +327,11 @@ export async function POST(req: Request) {
287
327
  }
288
328
  }
289
329
 
330
+ if (CLI_PROVIDERS.has(provider as CliSetupProvider)) {
331
+ const result = checkCliProvider(provider as CliSetupProvider)
332
+ return NextResponse.json(result)
333
+ }
334
+
290
335
  if (!provider) {
291
336
  return NextResponse.json({ ok: false, message: 'Provider is required.' }, { status: 400 })
292
337
  }
@@ -171,6 +171,11 @@ export async function GET(req: Request) {
171
171
  { id: 'claude-cli', label: 'Claude Code CLI', command: 'claude' },
172
172
  { id: 'codex-cli', label: 'OpenAI Codex CLI', command: 'codex' },
173
173
  { id: 'opencode-cli', label: 'OpenCode CLI', command: 'opencode' },
174
+ { id: 'gemini-cli', label: 'Gemini CLI', command: 'gemini' },
175
+ { id: 'copilot-cli', label: 'GitHub Copilot CLI', command: 'copilot' },
176
+ { id: 'cursor-cli', label: 'Cursor Agent CLI', command: 'cursor-agent' },
177
+ { id: 'qwen-code-cli', label: 'Qwen Code CLI', command: 'qwen' },
178
+ { id: 'goose', label: 'Goose CLI', command: 'goose' },
174
179
  { id: 'google-workspace-cli', label: 'Google Workspace CLI', command: 'gws' },
175
180
  ]
176
181
 
@@ -113,6 +113,17 @@ test('legacy-routed binary commands fall back to platform-api-key.txt', () => {
113
113
  fs.rmSync(tmpDir, { recursive: true, force: true })
114
114
  })
115
115
 
116
+ test('legacy-routed binary commands accept documented global aliases after the subcommand', () => {
117
+ const { result, capture } = runWithMockedFetch(
118
+ ['agents', 'list', '--access-key', 'alias-key', '--base-url', 'http://127.0.0.1:4567', '--json'],
119
+ )
120
+
121
+ assert.equal(result.status, 0, result.stderr)
122
+ assert.equal(result.stdout.trim(), '[]')
123
+ assert.equal(capture.headers['X-Access-Key'], 'alias-key')
124
+ assert.equal(capture.url, 'http://127.0.0.1:4567/api/agents')
125
+ })
126
+
116
127
  test('binary server help exits successfully', () => {
117
128
  const result = runBinary(['server', '--help'])
118
129
  assert.equal(result.status, 0, result.stderr)
package/src/cli/index.js CHANGED
@@ -15,7 +15,7 @@ const COMMAND_GROUPS = [
15
15
  description: 'Manage agents',
16
16
  commands: [
17
17
  cmd('list', 'GET', '/agents', 'List agents'),
18
- cmd('get', 'GET', '/agents/:id', 'Get an agent by id', { virtual: true, clientGetRoute: '/agents' }),
18
+ cmd('get', 'GET', '/agents/:id', 'Get an agent by id'),
19
19
  cmd('create', 'POST', '/agents', 'Create an agent', { expectsJsonBody: true }),
20
20
  cmd('update', 'PUT', '/agents/:id', 'Update an agent', { expectsJsonBody: true }),
21
21
  cmd('delete', 'DELETE', '/agents/:id', 'Delete an agent'),
@@ -142,7 +142,7 @@ const COMMAND_GROUPS = [
142
142
  description: 'Manage encrypted provider credentials',
143
143
  commands: [
144
144
  cmd('list', 'GET', '/credentials', 'List credentials'),
145
- cmd('get', 'GET', '/credentials/:id', 'Get credential metadata by id', { virtual: true, clientGetRoute: '/credentials' }),
145
+ cmd('get', 'GET', '/credentials/:id', 'Get credential metadata by id'),
146
146
  cmd('create', 'POST', '/credentials', 'Create credential', { expectsJsonBody: true }),
147
147
  cmd('delete', 'DELETE', '/credentials/:id', 'Delete credential'),
148
148
  ],
@@ -541,7 +541,7 @@ const COMMAND_GROUPS = [
541
541
  description: 'Manage schedules',
542
542
  commands: [
543
543
  cmd('list', 'GET', '/schedules', 'List schedules'),
544
- cmd('get', 'GET', '/schedules/:id', 'Get schedule by id', { virtual: true, clientGetRoute: '/schedules' }),
544
+ cmd('get', 'GET', '/schedules/:id', 'Get schedule by id'),
545
545
  cmd('create', 'POST', '/schedules', 'Create schedule', { expectsJsonBody: true }),
546
546
  cmd('update', 'PUT', '/schedules/:id', 'Update schedule', { expectsJsonBody: true }),
547
547
  cmd('delete', 'DELETE', '/schedules/:id', 'Delete schedule'),
@@ -553,7 +553,7 @@ const COMMAND_GROUPS = [
553
553
  description: 'Manage reusable encrypted secrets',
554
554
  commands: [
555
555
  cmd('list', 'GET', '/secrets', 'List secrets metadata'),
556
- cmd('get', 'GET', '/secrets/:id', 'Get secret metadata by id', { virtual: true, clientGetRoute: '/secrets' }),
556
+ cmd('get', 'GET', '/secrets/:id', 'Get secret metadata by id'),
557
557
  cmd('create', 'POST', '/secrets', 'Create secret', { expectsJsonBody: true }),
558
558
  cmd('update', 'PUT', '/secrets/:id', 'Update secret metadata', { expectsJsonBody: true }),
559
559
  cmd('delete', 'DELETE', '/secrets/:id', 'Delete secret'),
@@ -453,7 +453,10 @@ test('client-side collection lookups fail cleanly when the entity is missing', a
453
453
  const stdout = makeWritable()
454
454
  const stderr = makeWritable()
455
455
 
456
- const fetchImpl = async () => jsonResponse([{ id: 'agent-2', name: 'Other Agent' }])
456
+ const fetchImpl = async () => new Response(JSON.stringify({ error: 'Not found' }), {
457
+ status: 404,
458
+ headers: { 'content-type': 'application/json' },
459
+ })
457
460
 
458
461
  const exitCode = await runCli(
459
462
  ['agents', 'get', 'agent-1'],
@@ -468,7 +471,7 @@ test('client-side collection lookups fail cleanly when the entity is missing', a
468
471
 
469
472
  assert.equal(exitCode, 1)
470
473
  assert.equal(stdout.toString(), '')
471
- assert.match(stderr.toString(), /entity not found for id: agent-1/i)
474
+ assert.match(stderr.toString(), /not found/i)
472
475
  })
473
476
 
474
477
  test('runCli loads request JSON from @file inputs', async () => {