buckyos 0.7.131 → 0.7.133

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.
@@ -77,6 +77,8 @@ export interface PikgInspectionResult {
77
77
  app: {
78
78
  did: string
79
79
  app_id: string
80
+ name?: string
81
+ app_type: string
80
82
  version: string
81
83
  owner: string
82
84
  app_doc_object_id: string
@@ -574,6 +576,8 @@ export async function inspectPikg(path: string): Promise<PikgInspectionResult> {
574
576
  app: {
575
577
  did: String(appDoc.did),
576
578
  app_id: appIdFromDid(String(appDoc.did)),
579
+ ...(typeof appDoc.name === 'string' ? { name: appDoc.name } : {}),
580
+ app_type: String(appDoc.app_type),
577
581
  version: String(appDoc.version),
578
582
  owner: String(appDoc.owner),
579
583
  app_doc_object_id: validated.appDocObjectId,
@@ -852,7 +856,7 @@ export function validateAppDocShape(appDoc: Record<string, unknown>): void {
852
856
  expectNonEmptyString(appDoc[field], `APPDOC.${field}`)
853
857
  }
854
858
  if (appDoc.doc_type !== 'app') throw invalid('appdoc', 'APPDOC.doc_type must be app')
855
- if (!['service', 'dapp', 'web', 'agent'].includes(String(appDoc.app_type))) {
859
+ if (!['service', 'dapp', 'web', 'agent', 'agent_skills'].includes(String(appDoc.app_type))) {
856
860
  throw invalid('appdoc', 'APPDOC.app_type is invalid')
857
861
  }
858
862
  if (appDoc.name !== undefined) expectNonEmptyString(appDoc.name, 'APPDOC.name')
@@ -906,6 +910,12 @@ export function validateAppDocShape(appDoc: Record<string, unknown>): void {
906
910
  if (Object.keys(pkgList).length === 0) {
907
911
  throw invalid('appdoc', 'APPDOC.pkg_list must not be empty')
908
912
  }
913
+ if (appDoc.app_type === 'agent_skills') {
914
+ expectNonEmptyString(appDoc.name, 'APPDOC.name')
915
+ if (Object.keys(pkgList).join() !== 'agent_skills') {
916
+ throw invalid('appdoc', 'an agent_skills App must contain only the agent_skills sub package')
917
+ }
918
+ }
909
919
  deriveNamespaceForPackage(appDoc)
910
920
  }
911
921
 
@@ -0,0 +1,356 @@
1
+ import type { CommandDefinition, CommandModule, JsonSchema } from '../core/command.ts'
2
+ import type { CommandContext } from '../core/context.ts'
3
+ import { EXIT_INTERNAL, ToolError, UsageError } from '../core/errors.ts'
4
+ import { expectObject, inputString, parseTimestamp, requiredInputString } from '../core/service.ts'
5
+
6
+ // Share links of the Zone file service (nfs-server `share.*` over NFSP,
7
+ // buckyos notepads/share-content-mgr-tech-design-todo.md §3.6).
8
+ const OBJECT_OUTPUT: JsonSchema = { type: 'object', additionalProperties: true }
9
+ const DAY_MS = 86_400_000
10
+ const EXPIRES_HELP = '1d, 7d (default), 30d, none, or an RFC 3339 time'
11
+
12
+ export function createShareModule(): CommandModule {
13
+ return {
14
+ name: 'share',
15
+ summary: 'Share files and folders as links, and manage what you have shared',
16
+ commands: [
17
+ createCommand(),
18
+ listCommand(),
19
+ getCommand(),
20
+ updateCommand(),
21
+ stopCommand(),
22
+ statsCommand(),
23
+ ],
24
+ }
25
+ }
26
+
27
+ interface NfspSession {
28
+ call(method: string, args: Record<string, unknown>, write?: boolean): Promise<Record<string, unknown>>
29
+ }
30
+
31
+ async function openNfsp(ctx: CommandContext): Promise<NfspSession> {
32
+ const session = ctx.session
33
+ if (!session) throw new ToolError('AUTH_REQUIRED', 'authenticated session is required', 3)
34
+ const token = (await session.ensureValid()).token
35
+ const base = gatewayOrigin(ctx.connection.endpoint)
36
+ let seq = 0
37
+ const post = async (method: string, body: Record<string, unknown>) => {
38
+ const response = await fetch(`${base}/nfs/v1/${method}`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
41
+ body: JSON.stringify(body),
42
+ signal: ctx.signal,
43
+ })
44
+ let parsed: { ok?: boolean; result?: unknown; error?: Record<string, unknown> }
45
+ try {
46
+ parsed = await response.json()
47
+ } catch {
48
+ throw new ToolError('INVALID_SERVICE_RESPONSE', `file service answered HTTP ${response.status}`, EXIT_INTERNAL)
49
+ }
50
+ if (parsed.ok !== true) {
51
+ const error = parsed.error ?? {}
52
+ const reason = typeof error.reason === 'string' ? error.reason.toUpperCase() : String(error.code ?? 'INTERNAL')
53
+ throw new ToolError(`SHARE_${reason}`, String(error.message ?? 'share request failed'))
54
+ }
55
+ return expectObject(parsed.result, `${method} result`)
56
+ }
57
+ const hello = await post('hello', { args: { versions: ['nfsp/0'] } })
58
+ const sessionId = String(hello.session ?? '')
59
+ if (!Array.isArray(hello.features) || !hello.features.includes('share')) {
60
+ throw new ToolError('SHARE_UNSUPPORTED', 'the file service of this Zone does not support sharing')
61
+ }
62
+ return {
63
+ call: (method, args, write = false) =>
64
+ post(method, { session: sessionId, args: { ...args, source_hint: 'cli' }, ...(write ? { seq: ++seq } : {}) }),
65
+ }
66
+ }
67
+
68
+ function gatewayOrigin(endpoint: string): string {
69
+ const url = new URL(endpoint)
70
+ const marker = url.pathname.indexOf('/kapi')
71
+ url.pathname = marker >= 0 ? url.pathname.slice(0, marker) || '/' : '/'
72
+ url.search = ''
73
+ url.hash = ''
74
+ return url.toString().replace(/\/$/, '')
75
+ }
76
+
77
+ /** `--expires` → unix seconds, `null` for no expiry, `undefined` for the server default (7 days). */
78
+ export function parseExpires(value: unknown): number | null | undefined {
79
+ if (value === undefined) return undefined
80
+ const text = String(value).trim().toLowerCase()
81
+ if (text === 'none' || text === 'never') return null
82
+ const days = text.match(/^(\d+)d$/)
83
+ if (days) return Math.floor((Date.now() + Number(days[1]) * DAY_MS) / 1000)
84
+ const at = parseTimestamp(value, 'expires')
85
+ if (at === undefined) return undefined
86
+ if (at <= Date.now()) throw new UsageError('INVALID_ARGUMENT', 'expires must be in the future')
87
+ return Math.floor(at / 1000)
88
+ }
89
+
90
+ function sourceLocator(path: string): Record<string, unknown> {
91
+ if (path.startsWith('cyfs://')) return { uri: path }
92
+ return { path: path.startsWith('/') ? path : `/${path}` }
93
+ }
94
+
95
+ function summary(share: Record<string, unknown>): Record<string, unknown> {
96
+ const keep = ['share_id', 'kind', 'title', 'status', 'access', 'expires_at', 'url', 'target_app', 'delivery_state', 'source_path', 'revision', 'created_at', 'stats']
97
+ return Object.fromEntries(keep.filter((key) => share[key] !== undefined).map((key) => [key, share[key]]))
98
+ }
99
+
100
+ function createCommand(): CommandDefinition {
101
+ return {
102
+ verb: 'create',
103
+ summary: 'Share a file or folder as a link (a snapshot of its current content)',
104
+ positionals: [{ name: 'path', description: 'File or folder, e.g. /home/alice/report.pdf or cyfs:///home/alice/report.pdf', required: true }],
105
+ options: [
106
+ { name: 'expires', description: `Link expiry: ${EXPIRES_HELP}`, type: 'string' },
107
+ { name: 'password', description: 'Require this password to open the link', type: 'string' },
108
+ { name: 'title', description: 'Title shown to visitors', type: 'string' },
109
+ { name: 'no-download', property: 'no_download', description: 'Preview only, no download button', type: 'boolean' },
110
+ { name: 'wait', description: 'Wait for a large snapshot to finish', type: 'boolean' },
111
+ ],
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ path: { type: 'string', minLength: 1 },
116
+ expires: { type: 'string', minLength: 1 },
117
+ password: { type: 'string', minLength: 1 },
118
+ title: { type: 'string', minLength: 1 },
119
+ no_download: { type: 'boolean' },
120
+ wait: { type: 'boolean' },
121
+ },
122
+ required: ['path'],
123
+ additionalProperties: false,
124
+ },
125
+ outputSchema: OBJECT_OUTPUT,
126
+ resultSchemaVersion: 1,
127
+ access: { mode: 'fixed', level: 'write' },
128
+ asyncMode: 'either',
129
+ requiresSession: true,
130
+ examples: [
131
+ 'buckyos share create /home/alice/report.pdf',
132
+ 'buckyos share create /home/alice/photos --expires 30d --password s3cret',
133
+ ],
134
+ handler: async (ctx, input) => {
135
+ const nfsp = await openNfsp(ctx)
136
+ const expires = parseExpires(input.expires)
137
+ const password = inputString(input, 'password')
138
+ const policy: Record<string, unknown> = {
139
+ access: password ? 'password' : 'holder',
140
+ allow_download: input.no_download !== true,
141
+ ...(password ? { password } : {}),
142
+ ...(expires !== undefined ? { expires_at: expires } : {}),
143
+ }
144
+ let op = await nfsp.call('share.create', {
145
+ source: sourceLocator(requiredInputString(input, 'path')),
146
+ policy,
147
+ title: inputString(input, 'title'),
148
+ idempotency_key: ctx.idempotencyKey ?? crypto.randomUUID(),
149
+ }, true)
150
+ while (input.wait === true && (op.state === 'running' || op.state === 'preparing')) {
151
+ await new Promise((resolve) => setTimeout(resolve, 1000))
152
+ ctx.signal.throwIfAborted()
153
+ op = await nfsp.call('share.operation', { operation_id: op.operation_id })
154
+ }
155
+ if (op.state === 'failed' || op.state === 'canceled') {
156
+ const error = (op.error ?? {}) as Record<string, unknown>
157
+ throw new ToolError(`SHARE_${String(error.reason ?? op.state).toUpperCase()}`, String(error.message ?? `share ${op.state}`))
158
+ }
159
+ return {
160
+ operation_id: op.operation_id,
161
+ state: op.state,
162
+ share_id: op.share_id ?? null,
163
+ url: op.url ?? null,
164
+ source_changed: op.source_changed ?? false,
165
+ ...(op.share ? { share: summary(op.share as Record<string, unknown>) } : {}),
166
+ }
167
+ },
168
+ }
169
+ }
170
+
171
+ function listCommand(): CommandDefinition {
172
+ return {
173
+ verb: 'list',
174
+ summary: 'List your shares',
175
+ options: [
176
+ { name: 'kind', description: 'link or app_delivery', type: 'string', enum: ['link', 'app_delivery'] },
177
+ { name: 'status', description: 'active, stopped, expired, ...', type: 'string' },
178
+ { name: 'q', description: 'Search title and path', type: 'string' },
179
+ { name: 'include-hidden', property: 'include_hidden', description: 'Include hidden shares', type: 'boolean' },
180
+ { name: 'cursor', description: 'Page cursor', type: 'string' },
181
+ { name: 'limit', description: 'Page size, at most 200', type: 'integer' },
182
+ ],
183
+ inputSchema: {
184
+ type: 'object',
185
+ properties: {
186
+ kind: { type: 'string', enum: ['link', 'app_delivery'] },
187
+ status: { type: 'string', minLength: 1 },
188
+ q: { type: 'string', minLength: 1 },
189
+ include_hidden: { type: 'boolean' },
190
+ cursor: { type: 'string', minLength: 1 },
191
+ limit: { type: 'integer', minimum: 1 },
192
+ },
193
+ additionalProperties: false,
194
+ },
195
+ outputSchema: OBJECT_OUTPUT,
196
+ resultSchemaVersion: 1,
197
+ access: { mode: 'fixed', level: 'read' },
198
+ asyncMode: 'sync',
199
+ requiresSession: true,
200
+ examples: ['buckyos share list --kind link --status active'],
201
+ handler: async (ctx, input) => {
202
+ const nfsp = await openNfsp(ctx)
203
+ const page = await nfsp.call('share.list', {
204
+ kind: input.kind,
205
+ status: input.status,
206
+ q: input.q,
207
+ include_hidden: input.include_hidden === true,
208
+ cursor: input.cursor,
209
+ limit: input.limit,
210
+ })
211
+ const items = Array.isArray(page.items) ? page.items as Record<string, unknown>[] : []
212
+ return { items: items.map(summary), next_cursor: page.next_cursor ?? null }
213
+ },
214
+ }
215
+ }
216
+
217
+ function getCommand(): CommandDefinition {
218
+ return {
219
+ verb: 'get',
220
+ summary: 'Show one share: link, policy, snapshot, delivery, statistics and history',
221
+ positionals: [{ name: 'share_id', description: 'Share ID (sh_…)', required: true }],
222
+ inputSchema: {
223
+ type: 'object',
224
+ properties: { share_id: { type: 'string', minLength: 1 } },
225
+ required: ['share_id'],
226
+ additionalProperties: false,
227
+ },
228
+ outputSchema: OBJECT_OUTPUT,
229
+ resultSchemaVersion: 1,
230
+ access: { mode: 'fixed', level: 'read' },
231
+ asyncMode: 'sync',
232
+ requiresSession: true,
233
+ examples: ['buckyos share get sh_0123456789abcdef'],
234
+ handler: async (ctx, input) => {
235
+ const nfsp = await openNfsp(ctx)
236
+ return await nfsp.call('share.get', { share_id: requiredInputString(input, 'share_id') })
237
+ },
238
+ }
239
+ }
240
+
241
+ function updateCommand(): CommandDefinition {
242
+ return {
243
+ verb: 'update',
244
+ summary: 'Change a link\'s expiry, password, title or download setting',
245
+ positionals: [{ name: 'share_id', description: 'Share ID (sh_…)', required: true }],
246
+ options: [
247
+ { name: 'expires', description: `New expiry: ${EXPIRES_HELP}`, type: 'string' },
248
+ { name: 'password', description: 'Set or change the password', type: 'string' },
249
+ { name: 'no-password', property: 'no_password', description: 'Remove the password', type: 'boolean' },
250
+ { name: 'title', description: 'New title', type: 'string' },
251
+ { name: 'download', description: 'Allow (true) or forbid (false) downloading', type: 'boolean' },
252
+ ],
253
+ inputSchema: {
254
+ type: 'object',
255
+ properties: {
256
+ share_id: { type: 'string', minLength: 1 },
257
+ expires: { type: 'string', minLength: 1 },
258
+ password: { type: 'string', minLength: 1 },
259
+ no_password: { type: 'boolean' },
260
+ title: { type: 'string', minLength: 1 },
261
+ download: { type: 'boolean' },
262
+ },
263
+ required: ['share_id'],
264
+ additionalProperties: false,
265
+ },
266
+ outputSchema: OBJECT_OUTPUT,
267
+ resultSchemaVersion: 1,
268
+ access: { mode: 'fixed', level: 'write' },
269
+ asyncMode: 'sync',
270
+ requiresSession: true,
271
+ examples: ['buckyos share update sh_0123456789abcdef --expires none', 'buckyos share update sh_0123456789abcdef --password n3w'],
272
+ handler: async (ctx, input) => {
273
+ if (input.password !== undefined && input.no_password === true) {
274
+ throw new UsageError('INVALID_ARGUMENT', 'use either --password or --no-password')
275
+ }
276
+ const changes: Record<string, unknown> = {}
277
+ const expires = parseExpires(input.expires)
278
+ if (expires !== undefined) changes.expires_at = expires
279
+ if (input.password !== undefined) changes.password = String(input.password)
280
+ if (input.no_password === true) changes.password = null
281
+ if (input.title !== undefined) changes.title = String(input.title)
282
+ if (input.download !== undefined) changes.allow_download = input.download === true
283
+ if (!Object.keys(changes).length) throw new UsageError('INVALID_ARGUMENT', 'nothing to change')
284
+ const nfsp = await openNfsp(ctx)
285
+ const shareId = requiredInputString(input, 'share_id')
286
+ const current = await nfsp.call('share.get', { share_id: shareId })
287
+ const result = await nfsp.call('share.update', { share_id: shareId, revision: current.revision, changes }, true)
288
+ return { revision: result.revision, share: summary(expectObject(result.share, 'share.update share')) }
289
+ },
290
+ }
291
+ }
292
+
293
+ function stopCommand(): CommandDefinition {
294
+ return {
295
+ verb: 'stop',
296
+ summary: 'Stop a share: the link stops working and the kept snapshot is released',
297
+ positionals: [{ name: 'share_id', description: 'Share ID (sh_…)', required: true }],
298
+ options: [{ name: 'reason', description: 'Recorded in the share history', type: 'string' }],
299
+ inputSchema: {
300
+ type: 'object',
301
+ properties: { share_id: { type: 'string', minLength: 1 }, reason: { type: 'string', minLength: 1 } },
302
+ required: ['share_id'],
303
+ additionalProperties: false,
304
+ },
305
+ outputSchema: OBJECT_OUTPUT,
306
+ resultSchemaVersion: 1,
307
+ access: { mode: 'fixed', level: 'destructive' },
308
+ asyncMode: 'sync',
309
+ requiresSession: true,
310
+ examples: ['buckyos share stop sh_0123456789abcdef --reason "sent by mistake"'],
311
+ handler: async (ctx, input) => {
312
+ const nfsp = await openNfsp(ctx)
313
+ const shareId = requiredInputString(input, 'share_id')
314
+ const current = await nfsp.call('share.get', { share_id: shareId })
315
+ return await nfsp.call('share.stop', { share_id: shareId, revision: current.revision, reason: inputString(input, 'reason') }, true)
316
+ },
317
+ }
318
+ }
319
+
320
+ function statsCommand(): CommandDefinition {
321
+ return {
322
+ verb: 'stats',
323
+ summary: 'Views, downloads, traffic and visitors of one share or all of yours',
324
+ options: [
325
+ { name: 'share-id', property: 'share_id', description: 'One share instead of all', type: 'string' },
326
+ { name: 'days', description: 'How many days back (default 30)', type: 'integer' },
327
+ { name: 'granularity', description: 'day or hour', type: 'string', enum: ['day', 'hour'] },
328
+ ],
329
+ inputSchema: {
330
+ type: 'object',
331
+ properties: {
332
+ share_id: { type: 'string', minLength: 1 },
333
+ days: { type: 'integer', minimum: 1 },
334
+ granularity: { type: 'string', enum: ['day', 'hour'] },
335
+ },
336
+ additionalProperties: false,
337
+ },
338
+ outputSchema: OBJECT_OUTPUT,
339
+ resultSchemaVersion: 1,
340
+ access: { mode: 'fixed', level: 'read' },
341
+ asyncMode: 'sync',
342
+ requiresSession: true,
343
+ examples: ['buckyos share stats --days 7', 'buckyos share stats --share-id sh_0123456789abcdef --granularity hour --days 2'],
344
+ handler: async (ctx, input) => {
345
+ const nfsp = await openNfsp(ctx)
346
+ const to = Math.floor(Date.now() / 1000) + 1
347
+ const days = typeof input.days === 'number' ? input.days : 30
348
+ return await nfsp.call('share.stats', {
349
+ share_id: inputString(input, 'share_id'),
350
+ from: to - days * 86_400,
351
+ to,
352
+ granularity: input.granularity ?? 'day',
353
+ })
354
+ },
355
+ }
356
+ }
@@ -0,0 +1,256 @@
1
+ import type { CommandModule, JsonSchema, OptionDefinition } from '../core/command.ts'
2
+ import type { CommandContext } from '../core/context.ts'
3
+ import { EXIT_INTERNAL, EXIT_PERMISSION, ToolError, UsageError } from '../core/errors.ts'
4
+ import { callService, requiredInputString } from '../core/service.ts'
5
+ import { getHost } from '../runtime/host.ts'
6
+ import { openZip, readTarGz, readZipEntry, sha256Bytes } from './pikg_archive.ts'
7
+ import { inspectPikg } from './pikg_protocol.ts'
8
+
9
+ const OBJECT_OUTPUT: JsonSchema = { type: 'object', additionalProperties: true }
10
+ const SKILL_KINDS = ['cmd', 'goal']
11
+ const SKILLS_SUB_PKG = 'agent_skills'
12
+ const SKILL_DIRS = ['cmd_skills', 'goal_skills', 'tools']
13
+ const MAX_SKILL_PAYLOAD_BYTES = 64 * 1024 * 1024
14
+ const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
15
+
16
+ const AGENT_OPTION: OptionDefinition = {
17
+ name: 'agent',
18
+ property: 'agent_id',
19
+ description: 'Agent ID; the Agent serves kRPC at /kapi/<agent_id>',
20
+ type: 'string',
21
+ required: true,
22
+ }
23
+
24
+ const KIND_OPTION: OptionDefinition = {
25
+ name: 'kind',
26
+ description: 'Skill kind',
27
+ type: 'string',
28
+ enum: SKILL_KINDS,
29
+ }
30
+
31
+ export function createSkillModule(): CommandModule {
32
+ return {
33
+ name: 'skill',
34
+ summary: 'List, install, and remove the cmd/goal skills of an Agent',
35
+ commands: [
36
+ {
37
+ verb: 'list',
38
+ summary: 'List the skills of an Agent',
39
+ options: [AGENT_OPTION, KIND_OPTION],
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ agent_id: { type: 'string', minLength: 1 },
44
+ kind: { type: 'string', enum: SKILL_KINDS },
45
+ },
46
+ required: ['agent_id'],
47
+ additionalProperties: false,
48
+ },
49
+ outputSchema: OBJECT_OUTPUT,
50
+ resultSchemaVersion: 1,
51
+ access: { mode: 'fixed', level: 'read' },
52
+ asyncMode: 'sync',
53
+ requiresSession: true,
54
+ examples: [
55
+ 'buckyos skill list --agent jarvis.example.zone',
56
+ 'buckyos skill list --agent jarvis.example.zone --kind cmd',
57
+ ],
58
+ handler: async (ctx, input) => {
59
+ const agentId = agentIdFrom(input)
60
+ const kind = typeof input.kind === 'string' ? input.kind : undefined
61
+ const skills = await listSkills(ctx, agentId, kind)
62
+ return { agent_id: agentId, ...(kind ? { kind } : {}), total: skills.length, skills }
63
+ },
64
+ },
65
+ {
66
+ verb: 'install',
67
+ summary: 'Install the skills of a skill PIKG into an Agent',
68
+ description:
69
+ 'Verifies the PIKG offline, requires app_type agent_skills, and sends the files of its agent_skills sub package (cmd_skills/, goal_skills/, tools/) to the Agent. Sessions created before keep their frozen manuals.',
70
+ positionals: [{ name: 'pikg_path', description: 'Local skill .pikg file' }],
71
+ options: [
72
+ AGENT_OPTION,
73
+ {
74
+ name: 'replace',
75
+ description: 'Replace skills of the same names from another package',
76
+ type: 'boolean',
77
+ },
78
+ ],
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ pikg_path: { type: 'string', minLength: 1 },
83
+ agent_id: { type: 'string', minLength: 1 },
84
+ replace: { type: 'boolean' },
85
+ },
86
+ required: ['pikg_path', 'agent_id'],
87
+ additionalProperties: false,
88
+ },
89
+ outputSchema: OBJECT_OUTPUT,
90
+ resultSchemaVersion: 1,
91
+ access: { mode: 'fixed', level: 'write' },
92
+ asyncMode: 'sync',
93
+ requiresSession: true,
94
+ examples: [
95
+ 'buckyos skill install ./dapp_dist/hello-skills-0.1.0.pikg --agent jarvis.example.zone',
96
+ 'buckyos skill install ./hello-skills-0.2.0.pikg --agent jarvis.example.zone --replace',
97
+ ],
98
+ handler: async (ctx, input) => await installSkills(ctx, input),
99
+ },
100
+ {
101
+ verb: 'remove',
102
+ summary: 'Remove an installed skill from an Agent',
103
+ description:
104
+ 'Only installed skills can be removed. Without --kind the kind is looked up by name.',
105
+ positionals: [{ name: 'name', description: 'Skill name' }],
106
+ options: [AGENT_OPTION, KIND_OPTION],
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ name: { type: 'string', minLength: 1 },
111
+ agent_id: { type: 'string', minLength: 1 },
112
+ kind: { type: 'string', enum: SKILL_KINDS },
113
+ },
114
+ required: ['name', 'agent_id'],
115
+ additionalProperties: false,
116
+ },
117
+ outputSchema: OBJECT_OUTPUT,
118
+ resultSchemaVersion: 1,
119
+ access: { mode: 'fixed', level: 'destructive' },
120
+ asyncMode: 'sync',
121
+ requiresSession: true,
122
+ examples: ['buckyos --yes skill remove hello --agent jarvis.example.zone --kind cmd'],
123
+ handler: async (ctx, input) => await removeSkill(ctx, input),
124
+ },
125
+ ],
126
+ }
127
+ }
128
+
129
+ async function installSkills(
130
+ ctx: CommandContext,
131
+ input: Record<string, unknown>,
132
+ ): Promise<Record<string, unknown>> {
133
+ const agentId = agentIdFrom(input)
134
+ const raw = requiredInputString(input, 'pikg_path')
135
+ const path = getHost().path.isAbsolute(raw)
136
+ ? getHost().path.resolve(raw)
137
+ : getHost().path.resolve(ctx.cwd, raw)
138
+ const inspection = await inspectPikg(path)
139
+ if (inspection.app.app_type !== SKILLS_SUB_PKG) {
140
+ throw new UsageError(
141
+ 'NOT_A_SKILL_PACKAGE',
142
+ `PIKG app_type is ${inspection.app.app_type}; a skill package has app_type ${SKILLS_SUB_PKG} (buckyos pikg init --kind agent-skills)`,
143
+ )
144
+ }
145
+ const payload = inspection.subpackages.find((item) => item.key === SKILLS_SUB_PKG)
146
+ ?.payload as { path: string; digest: string }
147
+ const archive = await openZip(path)
148
+ const bytes = await readZipEntry(
149
+ archive,
150
+ archive.byName.get(payload.path)!,
151
+ MAX_SKILL_PAYLOAD_BYTES,
152
+ )
153
+ if (`sha256:${sha256Bytes(bytes)}` !== payload.digest) {
154
+ throw new ToolError('INVALID_PACKAGE', 'PIKG changed after it was verified')
155
+ }
156
+ const files: Record<string, { content: string; executable: boolean }> = {}
157
+ const ignored: string[] = []
158
+ for (const entry of await readTarGz(bytes, MAX_SKILL_PAYLOAD_BYTES)) {
159
+ if (!SKILL_DIRS.includes(entry.path.split('/')[0])) {
160
+ ignored.push(entry.path)
161
+ continue
162
+ }
163
+ files[entry.path] = { content: base64(entry.bytes), executable: entry.executable }
164
+ }
165
+ const result = await callService<unknown>(ctx, agentId, 'skills.install', {
166
+ package: {
167
+ package: inspection.app.name,
168
+ version: inspection.app.version,
169
+ pikg_digest: inspection.pikg_digest,
170
+ files,
171
+ },
172
+ replace: input.replace === true,
173
+ })
174
+ return {
175
+ agent_id: agentId,
176
+ pikg_path: inspection.pikg_path,
177
+ pikg_digest: inspection.pikg_digest,
178
+ ...expectObject(result, 'skills.install'),
179
+ ...(ignored.length ? { ignored_files: ignored } : {}),
180
+ }
181
+ }
182
+
183
+ async function removeSkill(
184
+ ctx: CommandContext,
185
+ input: Record<string, unknown>,
186
+ ): Promise<Record<string, unknown>> {
187
+ const agentId = agentIdFrom(input)
188
+ const name = requiredInputString(input, 'name')
189
+ let kind = typeof input.kind === 'string' ? input.kind : undefined
190
+ if (!kind) {
191
+ const kinds = (await listSkills(ctx, agentId)).filter((skill) => skill.name === name)
192
+ .map((skill) => String(skill.kind))
193
+ if (kinds.length === 0) {
194
+ throw new ToolError('RESOURCE_NOT_FOUND', `Agent ${agentId} has no skill named ${name}`)
195
+ }
196
+ if (kinds.length > 1) {
197
+ throw new UsageError('AMBIGUOUS_SKILL', `${name} is both a cmd and a goal skill; use --kind`)
198
+ }
199
+ kind = kinds[0]
200
+ }
201
+ if (!ctx.confirmed) {
202
+ if (ctx.config.nonInteractive || !ctx.io.inputIsTerminal) {
203
+ throw new ToolError(
204
+ 'CONFIRMATION_REQUIRED',
205
+ 'skill remove requires --yes in non-interactive execution',
206
+ EXIT_PERMISSION,
207
+ )
208
+ }
209
+ const answer = (await ctx.io.prompt(`Remove ${kind} skill ${name} from ${agentId}? [y/N] `))
210
+ ?.trim().toLowerCase()
211
+ if (answer !== 'y' && answer !== 'yes') {
212
+ throw new ToolError('CONFIRMATION_DECLINED', 'skill remove was declined', EXIT_PERMISSION)
213
+ }
214
+ }
215
+ const removed = await callService<unknown>(ctx, agentId, 'skills.remove', { kind, name })
216
+ return { agent_id: agentId, removed: expectObject(removed, 'skills.remove') }
217
+ }
218
+
219
+ async function listSkills(
220
+ ctx: CommandContext,
221
+ agentId: string,
222
+ kind?: string,
223
+ ): Promise<Record<string, unknown>[]> {
224
+ const skills = await callService<unknown>(ctx, agentId, 'skills.list', kind ? { kind } : {})
225
+ if (!Array.isArray(skills)) invalidResponse('skills.list')
226
+ return (skills as unknown[]).map((skill) => expectObject(skill, 'skills.list'))
227
+ }
228
+
229
+ function agentIdFrom(input: Record<string, unknown>): string {
230
+ const agentId = requiredInputString(input, 'agent_id')
231
+ if (!AGENT_ID.test(agentId)) {
232
+ throw new UsageError('INVALID_AGENT_ID', `invalid Agent ID: ${agentId}`)
233
+ }
234
+ return agentId
235
+ }
236
+
237
+ function expectObject(value: unknown, method: string): Record<string, unknown> {
238
+ if (!value || typeof value !== 'object' || Array.isArray(value)) invalidResponse(method)
239
+ return value as Record<string, unknown>
240
+ }
241
+
242
+ function invalidResponse(method: string): never {
243
+ throw new ToolError(
244
+ 'INVALID_SERVICE_RESPONSE',
245
+ `Agent returned an invalid response for ${method}`,
246
+ EXIT_INTERNAL,
247
+ )
248
+ }
249
+
250
+ function base64(bytes: Uint8Array): string {
251
+ let binary = ''
252
+ for (let index = 0; index < bytes.length; index += 0x8000) {
253
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000))
254
+ }
255
+ return btoa(binary)
256
+ }