nebula-ai-plugin-system 0.1.0
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.
- package/README.md +17 -0
- package/package.json +49 -0
- package/src/browser.ts +713 -0
- package/src/code-execute.ts +151 -0
- package/src/cwd-state.ts +33 -0
- package/src/delegate.ts +88 -0
- package/src/fs.ts +0 -0
- package/src/index.ts +160 -0
- package/src/session-search.ts +103 -0
- package/src/shell-cd.ts +73 -0
- package/src/shell-process.ts +255 -0
- package/src/shell.ts +104 -0
- package/src/skills-manage.ts +126 -0
- package/src/skills.ts +107 -0
- package/src/todo.ts +91 -0
- package/src/vision.ts +242 -0
- package/src/web-fetch.ts +310 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
LocalBackend,
|
|
4
|
+
type SandboxBackend,
|
|
5
|
+
type ToolDef,
|
|
6
|
+
coerceBool,
|
|
7
|
+
redactEnv,
|
|
8
|
+
} from 'nebula-ai-core'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { type WorkingDirState, resolveCwd } from './cwd-state'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* v0.9.3 split: long-running subprocess management is FOUR flat tools, not
|
|
14
|
+
* a discriminated union. qwen3.6-plus narrates results when faced with
|
|
15
|
+
* `action: 'start'|'output'|'list'|'kill'` schemas (regression #1). Flat
|
|
16
|
+
* z.object schemas remove that footgun.
|
|
17
|
+
*
|
|
18
|
+
* - `shell.process_start` — spawn a backgrounded command, returns id
|
|
19
|
+
* - `shell.process_output` — read stdout/stderr by id
|
|
20
|
+
* - `shell.process_list` — list active + recently-exited entries
|
|
21
|
+
* - `shell.process_kill` — terminate by id (SIGTERM / SIGKILL / SIGINT)
|
|
22
|
+
*
|
|
23
|
+
* State lives in module-level memory shared across the four tools; the
|
|
24
|
+
* process tree is killed when nebula exits via killAllProcesses().
|
|
25
|
+
*
|
|
26
|
+
* Distinct from `shell.run` which waits for completion (one-shot
|
|
27
|
+
* commands). shell.process_start backgrounds it (dev servers, watchers).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
interface BackgroundProcess {
|
|
31
|
+
id: string
|
|
32
|
+
command: string
|
|
33
|
+
cwd: string
|
|
34
|
+
proc: ChildProcess
|
|
35
|
+
stdout: string
|
|
36
|
+
stderr: string
|
|
37
|
+
startedAt: number
|
|
38
|
+
exitCode: number | null
|
|
39
|
+
exitedAt: number | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const processes = new Map<string, BackgroundProcess>()
|
|
43
|
+
|
|
44
|
+
interface ShellProcessDeps {
|
|
45
|
+
/**
|
|
46
|
+
* Working directory. Pass a `WorkingDirState` to share with `shell.cd`
|
|
47
|
+
* (production); a plain string for a fixed cwd (tests).
|
|
48
|
+
*/
|
|
49
|
+
cwd: string | WorkingDirState
|
|
50
|
+
/** Phase 9.5: sandbox wraps the long-running spawn. LocalBackend = passthrough. Optional for back-compat. */
|
|
51
|
+
sandbox?: SandboxBackend
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const StartSchema = z.object({
|
|
55
|
+
command: z.string().min(1).describe('Command to run via /bin/sh -c, in the background.'),
|
|
56
|
+
cwd: z.string().optional().describe('Working directory override. Defaults to nebula cwd.'),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const OutputSchema = z.object({
|
|
60
|
+
id: z.string().min(1).describe('Process id from a prior shell.process_start.'),
|
|
61
|
+
clear: coerceBool.optional().describe('Clear captured output after returning. Default false.'),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const ListSchema = z.object({})
|
|
65
|
+
|
|
66
|
+
const KillSchema = z.object({
|
|
67
|
+
id: z.string().min(1).describe('Process id from a prior shell.process_start.'),
|
|
68
|
+
signal: z
|
|
69
|
+
.enum(['SIGTERM', 'SIGKILL', 'SIGINT'])
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Signal to send. Default SIGTERM.'),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export function makeShellProcessStart(
|
|
75
|
+
deps: ShellProcessDeps,
|
|
76
|
+
): ToolDef<z.infer<typeof StartSchema>> {
|
|
77
|
+
const sandbox = deps.sandbox ?? new LocalBackend()
|
|
78
|
+
const cwdState = resolveCwd(deps.cwd)
|
|
79
|
+
return {
|
|
80
|
+
name: 'shell.process_start',
|
|
81
|
+
description:
|
|
82
|
+
'Spawn a backgrounded shell command and return its id. Use for dev servers, watchers, REPLs, anything you need to keep running while you do other things. For one-shot commands, use shell.run instead.',
|
|
83
|
+
searchHint: 'shell process spawn background daemon long running start',
|
|
84
|
+
schema: StartSchema,
|
|
85
|
+
handler: async args => startProcess(args.command, args.cwd ?? cwdState.get(), sandbox),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function makeShellProcessOutput(): ToolDef<z.infer<typeof OutputSchema>> {
|
|
90
|
+
return {
|
|
91
|
+
name: 'shell.process_output',
|
|
92
|
+
description:
|
|
93
|
+
'Read accumulated stdout + stderr of a backgrounded process by id. Returns running flag and exit_code (null while running). Set clear=true to drain the buffer after reading.',
|
|
94
|
+
searchHint: 'shell process output stdout stderr capture read',
|
|
95
|
+
schema: OutputSchema,
|
|
96
|
+
handler: async args => captureOutput(args.id, args.clear ?? false),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function makeShellProcessList(): ToolDef<z.infer<typeof ListSchema>> {
|
|
101
|
+
return {
|
|
102
|
+
name: 'shell.process_list',
|
|
103
|
+
description:
|
|
104
|
+
'List all backgrounded processes (active and recently-exited) with their commands, cwd, and status.',
|
|
105
|
+
searchHint: 'shell process list active running daemons subprocesses',
|
|
106
|
+
schema: ListSchema,
|
|
107
|
+
handler: async () => listProcesses(),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function makeShellProcessKill(): ToolDef<z.infer<typeof KillSchema>> {
|
|
112
|
+
return {
|
|
113
|
+
name: 'shell.process_kill',
|
|
114
|
+
description:
|
|
115
|
+
'Terminate a backgrounded process by id. Default signal is SIGTERM. Returns killed=true if a live process was signalled, killed=false if it had already exited.',
|
|
116
|
+
searchHint: 'shell process kill terminate sigterm sigkill stop',
|
|
117
|
+
schema: KillSchema,
|
|
118
|
+
handler: async args => killProcess(args.id, args.signal ?? 'SIGTERM'),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function startProcess(
|
|
123
|
+
command: string,
|
|
124
|
+
cwd: string,
|
|
125
|
+
sandbox: SandboxBackend,
|
|
126
|
+
): Promise<{ ok: boolean; data?: { id: string }; error?: string }> {
|
|
127
|
+
const id = `proc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
128
|
+
const { env } = redactEnv(process.env as Record<string, string>)
|
|
129
|
+
const wrapped = await sandbox.wrapSpawn({
|
|
130
|
+
command: '/bin/sh',
|
|
131
|
+
args: ['-c', command],
|
|
132
|
+
options: { cwd, env },
|
|
133
|
+
})
|
|
134
|
+
const proc = spawn(wrapped.command, wrapped.args, wrapped.options)
|
|
135
|
+
const entry: BackgroundProcess = {
|
|
136
|
+
id,
|
|
137
|
+
command,
|
|
138
|
+
cwd,
|
|
139
|
+
proc,
|
|
140
|
+
stdout: '',
|
|
141
|
+
stderr: '',
|
|
142
|
+
startedAt: Date.now(),
|
|
143
|
+
exitCode: null,
|
|
144
|
+
exitedAt: null,
|
|
145
|
+
}
|
|
146
|
+
processes.set(id, entry)
|
|
147
|
+
proc.stdout?.setEncoding('utf8')
|
|
148
|
+
proc.stderr?.setEncoding('utf8')
|
|
149
|
+
proc.stdout?.on('data', chunk => {
|
|
150
|
+
entry.stdout += chunk as string
|
|
151
|
+
if (entry.stdout.length > 200_000) entry.stdout = entry.stdout.slice(-200_000)
|
|
152
|
+
})
|
|
153
|
+
proc.stderr?.on('data', chunk => {
|
|
154
|
+
entry.stderr += chunk as string
|
|
155
|
+
if (entry.stderr.length > 100_000) entry.stderr = entry.stderr.slice(-100_000)
|
|
156
|
+
})
|
|
157
|
+
proc.on('exit', code => {
|
|
158
|
+
entry.exitCode = code
|
|
159
|
+
entry.exitedAt = Date.now()
|
|
160
|
+
})
|
|
161
|
+
return { ok: true, data: { id } }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function captureOutput(
|
|
165
|
+
id: string,
|
|
166
|
+
clear: boolean,
|
|
167
|
+
): {
|
|
168
|
+
ok: boolean
|
|
169
|
+
data?: {
|
|
170
|
+
id: string
|
|
171
|
+
stdout: string
|
|
172
|
+
stderr: string
|
|
173
|
+
exit_code: number | null
|
|
174
|
+
running: boolean
|
|
175
|
+
started_at: number
|
|
176
|
+
exited_at: number | null
|
|
177
|
+
}
|
|
178
|
+
error?: string
|
|
179
|
+
} {
|
|
180
|
+
const entry = processes.get(id)
|
|
181
|
+
if (!entry) return { ok: false, error: `unknown process: ${id}` }
|
|
182
|
+
const out = {
|
|
183
|
+
id,
|
|
184
|
+
stdout: entry.stdout,
|
|
185
|
+
stderr: entry.stderr,
|
|
186
|
+
exit_code: entry.exitCode,
|
|
187
|
+
running: entry.exitCode === null,
|
|
188
|
+
started_at: entry.startedAt,
|
|
189
|
+
exited_at: entry.exitedAt,
|
|
190
|
+
}
|
|
191
|
+
if (clear) {
|
|
192
|
+
entry.stdout = ''
|
|
193
|
+
entry.stderr = ''
|
|
194
|
+
}
|
|
195
|
+
if (entry.exitCode !== null && clear) {
|
|
196
|
+
processes.delete(id)
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, data: out }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function listProcesses(): {
|
|
202
|
+
ok: boolean
|
|
203
|
+
data: {
|
|
204
|
+
processes: Array<{
|
|
205
|
+
id: string
|
|
206
|
+
command: string
|
|
207
|
+
cwd: string
|
|
208
|
+
running: boolean
|
|
209
|
+
exit_code: number | null
|
|
210
|
+
started_at: number
|
|
211
|
+
}>
|
|
212
|
+
}
|
|
213
|
+
} {
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
data: {
|
|
217
|
+
processes: [...processes.values()].map(p => ({
|
|
218
|
+
id: p.id,
|
|
219
|
+
command: p.command,
|
|
220
|
+
cwd: p.cwd,
|
|
221
|
+
running: p.exitCode === null,
|
|
222
|
+
exit_code: p.exitCode,
|
|
223
|
+
started_at: p.startedAt,
|
|
224
|
+
})),
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function killProcess(
|
|
230
|
+
id: string,
|
|
231
|
+
signal: 'SIGTERM' | 'SIGKILL' | 'SIGINT',
|
|
232
|
+
): { ok: boolean; data?: { id: string; killed: boolean }; error?: string } {
|
|
233
|
+
const entry = processes.get(id)
|
|
234
|
+
if (!entry) return { ok: false, error: `unknown process: ${id}` }
|
|
235
|
+
if (entry.exitCode !== null) {
|
|
236
|
+
processes.delete(id)
|
|
237
|
+
return { ok: true, data: { id, killed: false } }
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
entry.proc.kill(signal)
|
|
241
|
+
return { ok: true, data: { id, killed: true } }
|
|
242
|
+
} catch (e) {
|
|
243
|
+
return { ok: false, error: (e as Error).message }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Kill all tracked processes. Called by chat.tsx on session exit. */
|
|
248
|
+
export function killAllProcesses(): void {
|
|
249
|
+
for (const entry of processes.values()) {
|
|
250
|
+
if (entry.exitCode !== null) continue
|
|
251
|
+
try {
|
|
252
|
+
entry.proc.kill('SIGTERM')
|
|
253
|
+
} catch {}
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { LocalBackend, type SandboxBackend, type ToolDef, redactEnv } from 'nebula-ai-core'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { type WorkingDirState, resolveCwd } from './cwd-state'
|
|
5
|
+
|
|
6
|
+
interface ShellToolDeps {
|
|
7
|
+
/**
|
|
8
|
+
* Working directory for spawned commands. Pass a `WorkingDirState` to share
|
|
9
|
+
* the cwd with `shell.cd` and other shell-class tools (production path), or
|
|
10
|
+
* a plain string for a fixed cwd (legacy + tests).
|
|
11
|
+
*/
|
|
12
|
+
cwd: string | WorkingDirState
|
|
13
|
+
/** Default timeout in ms. */
|
|
14
|
+
defaultTimeoutMs?: number
|
|
15
|
+
/**
|
|
16
|
+
* Phase 9.5: sandbox backend wraps the spawn before exec. Default
|
|
17
|
+
* `LocalBackend` is a passthrough (today's behaviour). When sandbox.mode='os'
|
|
18
|
+
* on darwin, this becomes a sandbox-exec wrapper that denies writes outside
|
|
19
|
+
* agentDir + workspaceRoot + /tmp/nebula-* + /var/folders. Optional for
|
|
20
|
+
* back-compat: tests + legacy callers that don't supply it get LocalBackend.
|
|
21
|
+
*/
|
|
22
|
+
sandbox?: SandboxBackend
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ShellSchema = z.object({
|
|
26
|
+
command: z
|
|
27
|
+
.string()
|
|
28
|
+
.min(1)
|
|
29
|
+
.describe('Shell command to run. Quoted, fully formed (e.g., `ls -la`).'),
|
|
30
|
+
timeout_ms: z
|
|
31
|
+
.number()
|
|
32
|
+
.int()
|
|
33
|
+
.positive()
|
|
34
|
+
.max(300_000)
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Override default 60s timeout.'),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export function makeShellRun(deps: ShellToolDeps): ToolDef<z.infer<typeof ShellSchema>> {
|
|
40
|
+
const sandbox = deps.sandbox ?? new LocalBackend()
|
|
41
|
+
const cwdState = resolveCwd(deps.cwd)
|
|
42
|
+
return {
|
|
43
|
+
name: 'shell.run',
|
|
44
|
+
description:
|
|
45
|
+
'Run a shell command in the agent workspace. Subject to permission approval (mode `prompt` or `strict`). Wallet/API-key environment variables are redacted before launch. Captures stdout/stderr; killed on timeout.',
|
|
46
|
+
searchHint: 'shell run bash command execute subprocess',
|
|
47
|
+
schema: ShellSchema,
|
|
48
|
+
handler: async args => {
|
|
49
|
+
const cwd = cwdState.get()
|
|
50
|
+
const timeout = args.timeout_ms ?? deps.defaultTimeoutMs ?? 60_000
|
|
51
|
+
const { env: redactedEnv, removed } = redactEnv(process.env)
|
|
52
|
+
// Build an explicit /bin/sh -c <cmd> argv so the sandbox backend can
|
|
53
|
+
// wrap it without colliding with shell:true semantics. The wrapper may
|
|
54
|
+
// prepend e.g. sandbox-exec -p <profile>; spawn must see a real argv.
|
|
55
|
+
const wrapped = await sandbox.wrapSpawn({
|
|
56
|
+
command: '/bin/sh',
|
|
57
|
+
args: ['-c', args.command],
|
|
58
|
+
options: { cwd, env: redactedEnv },
|
|
59
|
+
})
|
|
60
|
+
return new Promise(resolve => {
|
|
61
|
+
const child = spawn(wrapped.command, wrapped.args, wrapped.options)
|
|
62
|
+
let stdout = ''
|
|
63
|
+
let stderr = ''
|
|
64
|
+
let timedOut = false
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
timedOut = true
|
|
67
|
+
child.kill('SIGKILL')
|
|
68
|
+
}, timeout)
|
|
69
|
+
child.stdout?.on('data', chunk => {
|
|
70
|
+
stdout += String(chunk)
|
|
71
|
+
if (stdout.length > 200_000) stdout = stdout.slice(-200_000)
|
|
72
|
+
})
|
|
73
|
+
child.stderr?.on('data', chunk => {
|
|
74
|
+
stderr += String(chunk)
|
|
75
|
+
if (stderr.length > 200_000) stderr = stderr.slice(-200_000)
|
|
76
|
+
})
|
|
77
|
+
child.on('close', code => {
|
|
78
|
+
clearTimeout(timer)
|
|
79
|
+
resolve({
|
|
80
|
+
ok: !timedOut && code === 0,
|
|
81
|
+
data: {
|
|
82
|
+
command: args.command,
|
|
83
|
+
code,
|
|
84
|
+
timedOut,
|
|
85
|
+
stdout: stdout.slice(-32_000),
|
|
86
|
+
stderr: stderr.slice(-32_000),
|
|
87
|
+
cwd,
|
|
88
|
+
redactedEnvVars: removed,
|
|
89
|
+
},
|
|
90
|
+
...(timedOut
|
|
91
|
+
? { error: `command timed out after ${timeout}ms` }
|
|
92
|
+
: code !== 0
|
|
93
|
+
? { error: `exit code ${code}` }
|
|
94
|
+
: {}),
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
child.on('error', e => {
|
|
98
|
+
clearTimeout(timer)
|
|
99
|
+
resolve({ ok: false, error: e.message })
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { type ToolDef, scanSkills } from 'nebula-ai-core'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Phase 9.1 skills.manage. Persists per-skill on/off state into the user's
|
|
7
|
+
* nebula config (under `skills.disabled[]`). Re-scans the disk on every call so
|
|
8
|
+
* the user can install a new skill in another shell and the agent picks it up
|
|
9
|
+
* without restart.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface SkillsManageDeps {
|
|
13
|
+
importsClaudeCode: boolean
|
|
14
|
+
/** Path to ~/.nebula/config.ts. Used to read+rewrite the disabled list. */
|
|
15
|
+
configPath: string
|
|
16
|
+
/** When set, the manage tool reports this list as the active disabled set. */
|
|
17
|
+
disabledRef?: { current: string[] }
|
|
18
|
+
nebulaSkillsRoot?: string
|
|
19
|
+
claudeSkillsRoot?: string
|
|
20
|
+
claudePluginsCacheRoot?: string
|
|
21
|
+
nebulaPluginsRoot?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DISABLED_BLOCK_RE = /skills:\s*\{[^}]*disabled:\s*\[([\s\S]*?)\][^}]*\}/
|
|
25
|
+
|
|
26
|
+
const ManageSchema = z.object({
|
|
27
|
+
action: z
|
|
28
|
+
.enum(['list', 'enable', 'disable', 'refresh'])
|
|
29
|
+
.describe(
|
|
30
|
+
"'list' shows enabled+disabled skills; 'enable' / 'disable' flip a specific skill id; 'refresh' re-scans the disk.",
|
|
31
|
+
),
|
|
32
|
+
id: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Skill id to enable/disable. Required for enable/disable actions.'),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export function makeSkillsManage(deps: SkillsManageDeps): ToolDef<z.infer<typeof ManageSchema>> {
|
|
40
|
+
return {
|
|
41
|
+
name: 'skills.manage',
|
|
42
|
+
description:
|
|
43
|
+
"Enable/disable specific skills, or list all skills with their on/off state. Disabled skills are persisted in ~/.nebula/config.ts so the next session honors the choice. Use 'refresh' to re-scan the disk after installing a new skill.",
|
|
44
|
+
searchHint: 'skills manage enable disable toggle',
|
|
45
|
+
schema: ManageSchema,
|
|
46
|
+
handler: async args => {
|
|
47
|
+
const all = await scanSkills({
|
|
48
|
+
importsClaudeCode: deps.importsClaudeCode,
|
|
49
|
+
nebulaSkillsRoot: deps.nebulaSkillsRoot,
|
|
50
|
+
nebulaPluginsRoot: deps.nebulaPluginsRoot,
|
|
51
|
+
claudeSkillsRoot: deps.claudeSkillsRoot,
|
|
52
|
+
claudePluginsCacheRoot: deps.claudePluginsCacheRoot,
|
|
53
|
+
})
|
|
54
|
+
const disabled = new Set(deps.disabledRef?.current ?? [])
|
|
55
|
+
if (args.action === 'list' || args.action === 'refresh') {
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
data: {
|
|
59
|
+
total: all.length,
|
|
60
|
+
disabled: [...disabled].sort(),
|
|
61
|
+
skills: all
|
|
62
|
+
.map(s => ({
|
|
63
|
+
id: s.id,
|
|
64
|
+
source: s.source,
|
|
65
|
+
enabled: !disabled.has(s.id),
|
|
66
|
+
description: s.description,
|
|
67
|
+
}))
|
|
68
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!args.id) {
|
|
73
|
+
return { ok: false, error: 'id is required for enable/disable' }
|
|
74
|
+
}
|
|
75
|
+
const known = all.find(s => s.id === args.id)
|
|
76
|
+
if (!known) return { ok: false, error: `unknown skill: ${args.id}` }
|
|
77
|
+
if (args.action === 'enable') disabled.delete(args.id)
|
|
78
|
+
else disabled.add(args.id)
|
|
79
|
+
const next = [...disabled].sort()
|
|
80
|
+
try {
|
|
81
|
+
await persistDisabled(deps.configPath, next)
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { ok: false, error: `failed to update config: ${(e as Error).message}` }
|
|
84
|
+
}
|
|
85
|
+
if (deps.disabledRef) deps.disabledRef.current = next
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
data: { id: args.id, action: args.action, disabled: next },
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function persistDisabled(configPath: string, disabled: readonly string[]): Promise<void> {
|
|
95
|
+
let raw: string
|
|
96
|
+
try {
|
|
97
|
+
raw = await readFile(configPath, 'utf8')
|
|
98
|
+
} catch {
|
|
99
|
+
raw = ''
|
|
100
|
+
}
|
|
101
|
+
const block = `skills: { disabled: [${disabled.map(s => `'${s.replace(/'/g, "\\'")}'`).join(', ')}] }`
|
|
102
|
+
if (raw.includes('skills:')) {
|
|
103
|
+
const next = raw.replace(DISABLED_BLOCK_RE, block)
|
|
104
|
+
await writeFile(configPath, next, 'utf8')
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
// Insert just before the closing `}` of the top-level config object. Works
|
|
108
|
+
// for both `defineConfig({...})` and `export default {...}` shapes.
|
|
109
|
+
const inserted = injectKey(raw, block)
|
|
110
|
+
if (inserted) {
|
|
111
|
+
await writeFile(configPath, inserted, 'utf8')
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
await writeFile(configPath, `${raw}\n// nebula added: ${block}\n`, 'utf8')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function injectKey(raw: string, block: string): string | null {
|
|
118
|
+
// Find the last `}` that closes the config object (handles both
|
|
119
|
+
// `}\n` and `})\n` endings; strips trailing whitespace).
|
|
120
|
+
const closer = raw.match(/\n}\)?\s*$/)
|
|
121
|
+
if (!closer) return null
|
|
122
|
+
const before = raw.slice(0, closer.index)
|
|
123
|
+
const after = raw.slice(closer.index!)
|
|
124
|
+
const sep = before.trimEnd().endsWith(',') ? '\n' : ',\n'
|
|
125
|
+
return `${before.trimEnd()}${sep} ${block}${after}`
|
|
126
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { type SkillRef, type ToolDef, coerceInt, scanSkills } from 'nebula-ai-core'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Phase 9.1 skills surface. The scanner now lives in core (it's also used by
|
|
7
|
+
* the frozen-prefix builder + auto-trigger hook); these two tools are the
|
|
8
|
+
* brain-callable views.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface SkillsToolDeps {
|
|
12
|
+
importsClaudeCode: boolean
|
|
13
|
+
/** Override the nebula skills root. Defaults to agentPaths.skills. */
|
|
14
|
+
nebulaSkillsRoot?: string
|
|
15
|
+
/** Override the Claude Code skills root. Default: $HOME/.claude/skills. */
|
|
16
|
+
claudeSkillsRoot?: string
|
|
17
|
+
/** Override the Claude Code plugins cache root. Default: $HOME/.claude/plugins/cache. */
|
|
18
|
+
claudePluginsCacheRoot?: string
|
|
19
|
+
/** Override the nebula plugins root. Defaults to agentPaths.plugins. */
|
|
20
|
+
nebulaPluginsRoot?: string
|
|
21
|
+
/** Disabled skill ids (skills.manage persists this set into config). */
|
|
22
|
+
disabled?: readonly string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function discover(deps: SkillsToolDeps): Promise<SkillRef[]> {
|
|
26
|
+
const found = await scanSkills({
|
|
27
|
+
importsClaudeCode: deps.importsClaudeCode,
|
|
28
|
+
nebulaSkillsRoot: deps.nebulaSkillsRoot,
|
|
29
|
+
nebulaPluginsRoot: deps.nebulaPluginsRoot,
|
|
30
|
+
claudeSkillsRoot: deps.claudeSkillsRoot,
|
|
31
|
+
claudePluginsCacheRoot: deps.claudePluginsCacheRoot,
|
|
32
|
+
})
|
|
33
|
+
if (!deps.disabled || deps.disabled.length === 0) return found
|
|
34
|
+
const disabled = new Set(deps.disabled)
|
|
35
|
+
return found.filter(s => !disabled.has(s.id))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ListSchema = z.object({
|
|
39
|
+
source: z.enum(['nebula', 'nebula-plugin', 'claude-code', 'claude-plugin', 'all']).optional(),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export function makeSkillsList(deps: SkillsToolDeps): ToolDef<z.infer<typeof ListSchema>> {
|
|
43
|
+
return {
|
|
44
|
+
name: 'skills.list',
|
|
45
|
+
description:
|
|
46
|
+
'List skills from ~/.nebula/skills, ~/.nebula/plugins/<n>/skills, ~/.claude/skills, and ~/.claude/plugins/cache/<m>/<p>/<v>/skills (when imports.claudeCode). Returns id, name, description, source, path.',
|
|
47
|
+
searchHint: 'skills list catalog discover available',
|
|
48
|
+
schema: ListSchema,
|
|
49
|
+
handler: async args => {
|
|
50
|
+
const all = await discover(deps)
|
|
51
|
+
const filter = args.source ?? 'all'
|
|
52
|
+
const filtered = filter === 'all' ? all : all.filter(s => s.source === filter)
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
data: {
|
|
56
|
+
skills: filtered.map(s => ({
|
|
57
|
+
id: s.id,
|
|
58
|
+
name: s.frontmatter.name ?? s.name,
|
|
59
|
+
description: s.description,
|
|
60
|
+
path: s.path,
|
|
61
|
+
source: s.source,
|
|
62
|
+
filePattern: s.frontmatter.filePattern ?? null,
|
|
63
|
+
bashPattern: s.frontmatter.bashPattern ?? null,
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ViewSchema = z.object({
|
|
72
|
+
id: z.string().min(1).describe('Skill id from skills.list (e.g., "nebula:dogfood").'),
|
|
73
|
+
max_bytes: coerceInt.refine(n => n > 0 && n <= 200_000, 'max_bytes must be 1..200000').optional(),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export function makeSkillsView(deps: SkillsToolDeps): ToolDef<z.infer<typeof ViewSchema>> {
|
|
77
|
+
return {
|
|
78
|
+
name: 'skills.view',
|
|
79
|
+
description:
|
|
80
|
+
'Read the full SKILL.md body for a skill identified by `skills.list`. Use to inline a skill before applying it.',
|
|
81
|
+
searchHint: 'skills view read body content',
|
|
82
|
+
schema: ViewSchema,
|
|
83
|
+
handler: async args => {
|
|
84
|
+
const all = await discover(deps)
|
|
85
|
+
const skill = all.find(s => s.id === args.id)
|
|
86
|
+
if (!skill) return { ok: false, error: `unknown skill: ${args.id}` }
|
|
87
|
+
try {
|
|
88
|
+
const buf = await readFile(skill.path)
|
|
89
|
+
const cap = args.max_bytes ?? 100_000
|
|
90
|
+
const truncated = buf.byteLength > cap
|
|
91
|
+
const text = buf.subarray(0, Math.min(buf.byteLength, cap)).toString('utf8')
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
data: {
|
|
95
|
+
id: skill.id,
|
|
96
|
+
path: skill.path,
|
|
97
|
+
text,
|
|
98
|
+
bytes: buf.byteLength,
|
|
99
|
+
truncated,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return { ok: false, error: (e as Error).message }
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/todo.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-session task list. Brain uses this to plan multi-step work, similar to
|
|
6
|
+
* Claude Code's TodoWrite. State lives in-memory per session; when the
|
|
7
|
+
* process exits, the list is gone (this is intentional; persistent task
|
|
8
|
+
* state belongs in `memory.save` with `project` type).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface TodoItem {
|
|
12
|
+
id: string
|
|
13
|
+
text: string
|
|
14
|
+
status: 'pending' | 'in_progress' | 'completed'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TodoSchema = z.object({
|
|
18
|
+
action: z
|
|
19
|
+
.enum(['add', 'update', 'list', 'clear'])
|
|
20
|
+
.describe('add a task, update its status, list current tasks, or clear all.'),
|
|
21
|
+
id: z.string().optional().describe('Required for update; the task id returned from add.'),
|
|
22
|
+
text: z.string().optional().describe('Task description. Required for add.'),
|
|
23
|
+
status: z
|
|
24
|
+
.enum(['pending', 'in_progress', 'completed'])
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Required for update.'),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export function makeTodo(): ToolDef<z.infer<typeof TodoSchema>> {
|
|
30
|
+
const tasks: TodoItem[] = []
|
|
31
|
+
let next = 1
|
|
32
|
+
return {
|
|
33
|
+
name: 'todo',
|
|
34
|
+
description:
|
|
35
|
+
'Manage an in-session task list. Use to plan multi-step work; the list is shown to the user via post-tool-call rendering. Tasks reset when chat exits.',
|
|
36
|
+
searchHint: 'todo task plan steps tracker',
|
|
37
|
+
schema: TodoSchema,
|
|
38
|
+
handler: args => {
|
|
39
|
+
if (args.action === 'add') {
|
|
40
|
+
if (!args.text) return { ok: false, error: 'text is required for add' }
|
|
41
|
+
const id = String(next++)
|
|
42
|
+
tasks.push({ id, text: args.text, status: 'pending' })
|
|
43
|
+
return { ok: true, data: { id, tasks } }
|
|
44
|
+
}
|
|
45
|
+
if (args.action === 'update') {
|
|
46
|
+
if (!args.id || !args.status) {
|
|
47
|
+
return { ok: false, error: 'id + status required for update' }
|
|
48
|
+
}
|
|
49
|
+
const idx = tasks.findIndex(t => t.id === args.id)
|
|
50
|
+
if (idx === -1) return { ok: false, error: `unknown task: ${args.id}` }
|
|
51
|
+
tasks[idx] = { ...tasks[idx]!, status: args.status }
|
|
52
|
+
return { ok: true, data: { tasks } }
|
|
53
|
+
}
|
|
54
|
+
if (args.action === 'clear') {
|
|
55
|
+
tasks.length = 0
|
|
56
|
+
return { ok: true, data: { tasks } }
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, data: { tasks } }
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ClarifySchema = z.object({
|
|
64
|
+
question: z.string().min(3).describe('Question to ask the operator.'),
|
|
65
|
+
options: z
|
|
66
|
+
.array(z.string())
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('Optional multiple-choice options for the operator.'),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `clarify` is the brain's escape hatch when it doesn't have enough info to
|
|
73
|
+
* proceed. Phase 9.0 implementation surfaces the question via the tool result
|
|
74
|
+
* (chat.tsx renders it as a system row); the operator can answer in their next
|
|
75
|
+
* message. A future phase will add a structured pause.
|
|
76
|
+
*/
|
|
77
|
+
export function makeClarify(): ToolDef<z.infer<typeof ClarifySchema>> {
|
|
78
|
+
return {
|
|
79
|
+
name: 'clarify',
|
|
80
|
+
description:
|
|
81
|
+
'Ask the operator a question and surface it inline. Use when the next step requires information the brain does not have. The result echoes the question back for display; the operator answers in their next message.',
|
|
82
|
+
searchHint: 'clarify ask question prompt operator',
|
|
83
|
+
schema: ClarifySchema,
|
|
84
|
+
handler: args => {
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
data: { question: args.question, options: args.options ?? null },
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|