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.
@@ -0,0 +1,165 @@
1
+ import type { CommandModule, JsonSchema, PositionalDefinition } from '../core/command.ts'
2
+ import type { CommandContext } from '../core/context.ts'
3
+ import { EXIT_INTERNAL, ToolError, UsageError } from '../core/errors.ts'
4
+ import { callService, expectObject, requiredInputString } from '../core/service.ts'
5
+
6
+ const CONTROL_PANEL = 'control-panel'
7
+ const OBJECT_OUTPUT: JsonSchema = { type: 'object', additionalProperties: true }
8
+ const AGENT_POSITIONAL: PositionalDefinition = {
9
+ name: 'agent_id',
10
+ description: 'Agent ID as `agent list` prints it (raw host name, e.g. jarvis.example.zone)',
11
+ }
12
+ const AGENT_INPUT: JsonSchema = {
13
+ type: 'object',
14
+ properties: { agent_id: { type: 'string', minLength: 1 } },
15
+ required: ['agent_id'],
16
+ additionalProperties: false,
17
+ }
18
+ const PROFILE_FIELDS = ['display_name', 'bio']
19
+
20
+ // Control Panel answers for Agents the caller owns; admins (and the device identity) see all.
21
+ export function createAgentModule(): CommandModule {
22
+ return {
23
+ name: 'agent',
24
+ summary: 'List Agents and read or change their profiles',
25
+ commands: [
26
+ {
27
+ verb: 'list',
28
+ summary: 'List the Agents visible to the caller',
29
+ description: 'Owners see their own Agents; admins see every Agent in the Zone.',
30
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
31
+ outputSchema: {
32
+ type: 'object',
33
+ properties: { agents: { type: 'array', items: OBJECT_OUTPUT } },
34
+ required: ['agents'],
35
+ additionalProperties: false,
36
+ },
37
+ resultSchemaVersion: 1,
38
+ access: { mode: 'fixed', level: 'read' },
39
+ asyncMode: 'sync',
40
+ requiresSession: true,
41
+ examples: ['buckyos agent list'],
42
+ handler: async (ctx) => {
43
+ const response = await callAgent(ctx, 'agent.list', {})
44
+ if (!Array.isArray(response.agents)) {
45
+ throw new ToolError(
46
+ 'INVALID_SERVICE_RESPONSE',
47
+ 'agent.list agents must be an array',
48
+ EXIT_INTERNAL,
49
+ )
50
+ }
51
+ return { agents: response.agents }
52
+ },
53
+ },
54
+ {
55
+ verb: 'get',
56
+ summary: 'Get one Agent: profile, settings, install state, template, and runtime',
57
+ positionals: [AGENT_POSITIONAL],
58
+ inputSchema: AGENT_INPUT,
59
+ outputSchema: OBJECT_OUTPUT,
60
+ resultSchemaVersion: 1,
61
+ access: { mode: 'fixed', level: 'read' },
62
+ asyncMode: 'sync',
63
+ requiresSession: true,
64
+ examples: ['buckyos agent get jarvis.example.zone'],
65
+ handler: async (ctx, input) =>
66
+ await callAgent(ctx, 'agent.get', { agent_id: requiredInputString(input, 'agent_id') }),
67
+ },
68
+ {
69
+ verb: 'profile-get',
70
+ summary: 'Get the public profile (display name, bio, avatar) of an Agent',
71
+ positionals: [AGENT_POSITIONAL],
72
+ inputSchema: AGENT_INPUT,
73
+ outputSchema: OBJECT_OUTPUT,
74
+ resultSchemaVersion: 1,
75
+ access: { mode: 'fixed', level: 'read' },
76
+ asyncMode: 'sync',
77
+ requiresSession: true,
78
+ examples: ['buckyos agent profile-get jarvis.example.zone'],
79
+ handler: async (ctx, input) => {
80
+ const agentId = requiredInputString(input, 'agent_id')
81
+ return {
82
+ agent_id: agentId,
83
+ ...await callAgent(ctx, 'agent.profile.get', { agent_id: agentId }),
84
+ }
85
+ },
86
+ },
87
+ {
88
+ verb: 'profile-set',
89
+ summary: 'Change the display name or bio of an Agent',
90
+ description:
91
+ 'Only the given fields change; an empty value clears the field. Returns the profile before and after the change, and Message Center reloads the new name.',
92
+ positionals: [AGENT_POSITIONAL],
93
+ options: [
94
+ {
95
+ name: 'display-name',
96
+ description: 'New display name; empty clears it',
97
+ type: 'string',
98
+ },
99
+ { name: 'bio', description: 'New bio; empty clears it', type: 'string' },
100
+ ],
101
+ inputSchema: {
102
+ type: 'object',
103
+ properties: {
104
+ agent_id: { type: 'string', minLength: 1 },
105
+ display_name: { type: 'string' },
106
+ bio: { type: 'string' },
107
+ },
108
+ required: ['agent_id'],
109
+ additionalProperties: false,
110
+ },
111
+ outputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ agent_id: { type: 'string' },
115
+ before: OBJECT_OUTPUT,
116
+ profile: OBJECT_OUTPUT,
117
+ },
118
+ required: ['agent_id', 'before', 'profile'],
119
+ additionalProperties: false,
120
+ },
121
+ resultSchemaVersion: 1,
122
+ access: { mode: 'fixed', level: 'write' },
123
+ asyncMode: 'sync',
124
+ requiresSession: true,
125
+ examples: [
126
+ "buckyos agent profile-set jarvis.example.zone --display-name 'Jarvis'",
127
+ "buckyos agent profile-set jarvis.example.zone --bio ''",
128
+ ],
129
+ handler: async (ctx, input) => {
130
+ const agentId = requiredInputString(input, 'agent_id')
131
+ const changes = Object.fromEntries(
132
+ PROFILE_FIELDS.filter((field) => typeof input[field] === 'string')
133
+ .map((field) => [field, input[field]]),
134
+ )
135
+ if (Object.keys(changes).length === 0) {
136
+ throw new UsageError('INVALID_ARGUMENT', 'give --display-name or --bio')
137
+ }
138
+ const before = await callAgent(ctx, 'agent.profile.get', { agent_id: agentId })
139
+ const after = await callAgent(ctx, 'agent.profile.set', { agent_id: agentId, ...changes })
140
+ return {
141
+ agent_id: agentId,
142
+ before: expectObject(before.profile, 'agent.profile.get profile'),
143
+ profile: expectObject(after.profile, 'agent.profile.set profile'),
144
+ }
145
+ },
146
+ },
147
+ ],
148
+ }
149
+ }
150
+
151
+ async function callAgent(
152
+ ctx: CommandContext,
153
+ method: string,
154
+ params: Record<string, unknown>,
155
+ ): Promise<Record<string, unknown>> {
156
+ try {
157
+ return expectObject(await callService(ctx, CONTROL_PANEL, method, params), method)
158
+ } catch (error) {
159
+ const message = error instanceof Error ? error.message : String(error)
160
+ if (message.includes('agent_not_found')) {
161
+ throw new ToolError('RESOURCE_NOT_FOUND', `Agent not found or not yours: ${params.agent_id}`)
162
+ }
163
+ throw error
164
+ }
165
+ }
@@ -0,0 +1,160 @@
1
+ import type { CommandDefinition, CommandModule, JsonSchema } from '../core/command.ts'
2
+ import { UsageError } from '../core/errors.ts'
3
+ import {
4
+ callService,
5
+ expectObject,
6
+ inputString,
7
+ parseTimestamp,
8
+ splitServices,
9
+ } from '../core/service.ts'
10
+
11
+ const AICC = 'aicc'
12
+ const OBJECT_OUTPUT: JsonSchema = { type: 'object', additionalProperties: true }
13
+ const EMPTY_INPUT: JsonSchema = { type: 'object', properties: {}, additionalProperties: false }
14
+ const RANGES = ['last1d', 'last7d', 'last30d']
15
+ const GROUPS = [
16
+ 'provider_instance_name',
17
+ 'provider_model',
18
+ 'request_model',
19
+ 'method',
20
+ 'capability',
21
+ 'caller_app_id',
22
+ 'user_id',
23
+ 'tenant_id',
24
+ ]
25
+
26
+ // Read views of the AICC service (/kapi/aicc); provider and routing changes stay in Control Panel.
27
+ export function createAiModule(): CommandModule {
28
+ return {
29
+ name: 'ai',
30
+ summary: 'Read AI provider, routing, and usage state of the AICC service',
31
+ commands: [
32
+ readCommand({
33
+ verb: 'providers',
34
+ summary: 'List configured AI provider instances with auth, health, and model inventory',
35
+ method: 'provider.list',
36
+ description:
37
+ 'Credentials are reported only as configured/kind; secrets are never returned.',
38
+ }),
39
+ readCommand({
40
+ verb: 'routing',
41
+ summary: 'Get the AICC routing overlay and its settings revision',
42
+ method: 'routing.get',
43
+ description: 'An empty routing object means the default routing of the model catalog.',
44
+ }),
45
+ usageCommand(),
46
+ ],
47
+ }
48
+ }
49
+
50
+ function readCommand(options: {
51
+ verb: string
52
+ summary: string
53
+ method: string
54
+ description: string
55
+ }): CommandDefinition {
56
+ return {
57
+ verb: options.verb,
58
+ summary: options.summary,
59
+ description: options.description,
60
+ inputSchema: EMPTY_INPUT,
61
+ outputSchema: OBJECT_OUTPUT,
62
+ resultSchemaVersion: 1,
63
+ access: { mode: 'fixed', level: 'read' },
64
+ asyncMode: 'sync',
65
+ requiresSession: true,
66
+ examples: [`buckyos ai ${options.verb}`],
67
+ handler: async (ctx) =>
68
+ expectObject(await callService(ctx, AICC, options.method, {}), options.method),
69
+ }
70
+ }
71
+
72
+ function usageCommand(): CommandDefinition {
73
+ return {
74
+ verb: 'usage',
75
+ summary: "Aggregate the caller's AI usage: requests, tokens, and cost",
76
+ description:
77
+ "Scoped to the caller's own user (a device identity sees only its own calls, not the Zone total). Without --since the range defaults to last7d; --until defaults to now.",
78
+ options: [
79
+ {
80
+ name: 'range',
81
+ description: 'last1d, last7d (default), or last30d',
82
+ type: 'string',
83
+ enum: RANGES,
84
+ },
85
+ { name: 'since', description: 'RFC 3339 or Unix ms lower bound', type: 'string' },
86
+ { name: 'until', description: 'RFC 3339 or Unix ms upper bound', type: 'string' },
87
+ {
88
+ name: 'group-by',
89
+ description: `Comma-separated: ${GROUPS.join(', ')}`,
90
+ type: 'string',
91
+ },
92
+ {
93
+ name: 'bucket',
94
+ description: 'Time buckets: hour or day',
95
+ type: 'string',
96
+ enum: ['hour', 'day'],
97
+ },
98
+ { name: 'events', description: 'Also return the individual usage events', type: 'boolean' },
99
+ { name: 'limit', description: 'Event page size', type: 'integer' },
100
+ { name: 'cursor', description: 'Opaque event page cursor', type: 'string' },
101
+ ],
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ range: { type: 'string', enum: RANGES },
106
+ since: { type: 'string', minLength: 1 },
107
+ until: { type: 'string', minLength: 1 },
108
+ group_by: { type: 'string', minLength: 1 },
109
+ bucket: { type: 'string', enum: ['hour', 'day'] },
110
+ events: { type: 'boolean' },
111
+ limit: { type: 'integer', minimum: 1 },
112
+ cursor: { type: 'string', minLength: 1 },
113
+ },
114
+ additionalProperties: false,
115
+ },
116
+ outputSchema: OBJECT_OUTPUT,
117
+ resultSchemaVersion: 1,
118
+ access: { mode: 'fixed', level: 'read' },
119
+ asyncMode: 'sync',
120
+ requiresSession: true,
121
+ examples: [
122
+ 'buckyos ai usage --range last1d --group-by provider_instance_name,provider_model',
123
+ 'buckyos ai usage --since 2026-10-01T00:00:00Z --bucket day',
124
+ ],
125
+ handler: async (ctx, input) => {
126
+ const groupBy = splitServices(input.group_by)
127
+ const unknown = groupBy.filter((group) => !GROUPS.includes(group))
128
+ if (unknown.length > 0) {
129
+ throw new UsageError(
130
+ 'INVALID_ARGUMENT',
131
+ `unknown group-by ${unknown.join(', ')}; use ${GROUPS.join(', ')}`,
132
+ )
133
+ }
134
+ return expectObject(
135
+ await callService(ctx, AICC, 'usage.query', {
136
+ time_range: timeRange(input),
137
+ ...(groupBy.length > 0 ? { group_by: groupBy } : {}),
138
+ ...(input.bucket ? { time_bucket: input.bucket } : {}),
139
+ output_mode: input.events === true ? 'summary_and_events' : 'summary',
140
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
141
+ ...(inputString(input, 'cursor') ? { cursor: inputString(input, 'cursor') } : {}),
142
+ }),
143
+ 'usage.query',
144
+ )
145
+ },
146
+ }
147
+ }
148
+
149
+ function timeRange(input: Record<string, unknown>): Record<string, unknown> {
150
+ const since = parseTimestamp(input.since, 'since')
151
+ const until = parseTimestamp(input.until, 'until')
152
+ if (since === undefined) {
153
+ if (until !== undefined) throw new UsageError('INVALID_ARGUMENT', '--until requires --since')
154
+ return { kind: inputString(input, 'range') ?? 'last7d' }
155
+ }
156
+ if (input.range !== undefined) {
157
+ throw new UsageError('ARGUMENT_CONFLICT', '--range cannot be combined with --since')
158
+ }
159
+ return { kind: 'explicit', start_time_ms: since, end_time_ms: until ?? Date.now() }
160
+ }
@@ -97,6 +97,7 @@ export function createAppModule(dependencies: AppModuleDependencies = {}): Comma
97
97
  lifecycleCommand('stop', 'Stop an installed App'),
98
98
  restartCommand(),
99
99
  statusCommand(),
100
+ publishCommand(),
100
101
  ]
101
102
  return {
102
103
  name: 'app',
@@ -458,6 +459,91 @@ function statusCommand(): CommandDefinition {
458
459
  }
459
460
  }
460
461
 
462
+ function publishCommand(): CommandDefinition {
463
+ return {
464
+ verb: 'publish',
465
+ summary: 'Package a directory on the control-panel node into the Zone Repo as an App candidate',
466
+ description:
467
+ 'Calls control-panel app.publish: packages <local_dir> (a path on the control-panel node; nothing is uploaded) with the AppDoc template, stores PackageMeta and the AppDoc in the Zone Repo/NamedStore, and stages a self-checked PIKG there. The AppDoc is not Owner-signed and no BNS/App DID record is updated, so this is not an authoritative App release.',
468
+ positionals: [
469
+ { name: 'local_dir', description: 'Absolute source directory on the control-panel node' },
470
+ ],
471
+ options: [
472
+ {
473
+ name: 'app-doc',
474
+ property: 'app_doc',
475
+ description: 'AppDoc template JSON file (pkg_list entries without pkg_objid)',
476
+ type: 'string',
477
+ required: true,
478
+ },
479
+ {
480
+ name: 'app-type',
481
+ property: 'app_type',
482
+ description: 'App type; defaults to the template app_type',
483
+ type: 'string',
484
+ enum: ['dapp', 'web', 'agent'],
485
+ },
486
+ ],
487
+ inputSchema: {
488
+ type: 'object',
489
+ properties: {
490
+ local_dir: { type: 'string', minLength: 1 },
491
+ app_doc: { type: 'string', minLength: 1 },
492
+ app_type: { type: 'string', enum: ['dapp', 'web', 'agent'] },
493
+ },
494
+ required: ['local_dir', 'app_doc'],
495
+ additionalProperties: false,
496
+ },
497
+ outputSchema: OBJECT_OUTPUT,
498
+ resultSchemaVersion: 1,
499
+ access: { mode: 'fixed', level: 'write' },
500
+ asyncMode: 'sync',
501
+ requiresSession: true,
502
+ examples: [
503
+ 'buckyos app publish /srv/build/demo-web --app-doc ./demo-web.appdoc.json',
504
+ 'buckyos app publish /srv/build/demo-agent --app-doc ./demo-agent.appdoc.json --app-type agent',
505
+ ],
506
+ handler: async (ctx, input) => {
507
+ const localDir = expectString(input, 'local_dir')
508
+ if (!isAbsolute(localDir)) {
509
+ throw new UsageError(
510
+ 'INVALID_ARGUMENT',
511
+ 'local_dir must be an absolute path on the control-panel node',
512
+ )
513
+ }
514
+ let appDoc: unknown
515
+ try {
516
+ appDoc = JSON.parse(
517
+ await getHost().readTextFile(resolveFromCwd(ctx, expectString(input, 'app_doc'))),
518
+ )
519
+ } catch (error) {
520
+ throw new UsageError(
521
+ 'INVALID_APP_DOC',
522
+ `failed to read AppDoc template: ${errorMessage(error)}`,
523
+ )
524
+ }
525
+ if (!isObject(appDoc)) {
526
+ throw new UsageError('INVALID_APP_DOC', 'AppDoc template must be a JSON object')
527
+ }
528
+ const result = expectObject(
529
+ await callControl(ctx, 'app.publish', {
530
+ local_dir: localDir,
531
+ app_doc: appDoc,
532
+ ...(typeof input.app_type === 'string' ? { app_type: input.app_type } : {}),
533
+ }),
534
+ 'app.publish response',
535
+ )
536
+ const { ok: _ok, ...published } = result
537
+ return {
538
+ ...published,
539
+ publish_status: expectString(result, 'publish_status'),
540
+ app_doc_signature: 'not-signed',
541
+ app_did_publication: 'not-published',
542
+ }
543
+ },
544
+ }
545
+ }
546
+
461
547
  async function fetchApp(
462
548
  ctx: CommandContext,
463
549
  input: Record<string, unknown>,
@@ -12,6 +12,8 @@ import {
12
12
 
13
13
  const CONTROL_PANEL = 'control-panel'
14
14
  const OBJECT_OUTPUT: JsonSchema = { type: 'object', additionalProperties: true }
15
+ // Stored entry levels (control-panel sys_log_mgr normalize_log_level); WARN is stored as warning.
16
+ const LOG_LEVELS = ['error', 'warning', 'info', 'debug', 'trace']
15
17
 
16
18
  export interface LogModuleDependencies {
17
19
  sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>
@@ -21,8 +23,40 @@ export interface LogModuleDependencies {
21
23
  export function createLogModule(dependencies: LogModuleDependencies = {}): CommandModule {
22
24
  return {
23
25
  name: 'log',
24
- summary: 'Query, follow, and export redacted system logs',
25
- commands: [queryCommand(), tailCommand(dependencies), exportCommand(dependencies)],
26
+ summary: 'List services, query, follow, and export redacted system logs',
27
+ commands: [
28
+ servicesCommand(),
29
+ queryCommand(),
30
+ tailCommand(dependencies),
31
+ exportCommand(dependencies),
32
+ ],
33
+ }
34
+ }
35
+
36
+ function servicesCommand(): CommandDefinition {
37
+ return {
38
+ verb: 'services',
39
+ summary: 'List the service IDs that have system logs',
40
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
41
+ outputSchema: {
42
+ type: 'object',
43
+ properties: { services: { type: 'array', items: OBJECT_OUTPUT } },
44
+ required: ['services'],
45
+ additionalProperties: false,
46
+ },
47
+ resultSchemaVersion: 1,
48
+ access: { mode: 'fixed', level: 'read' },
49
+ asyncMode: 'sync',
50
+ requiresSession: true,
51
+ examples: ['buckyos log services'],
52
+ handler: async (ctx) => {
53
+ const response = expectObject(
54
+ await callService(ctx, CONTROL_PANEL, 'system.logs.list', {}),
55
+ 'Control Panel log service list',
56
+ )
57
+ if (!Array.isArray(response.services)) invalidResponse('log service list')
58
+ return { services: response.services }
59
+ },
26
60
  }
27
61
  }
28
62
 
@@ -178,7 +212,9 @@ function filterOptions(plural: boolean) {
178
212
  {
179
213
  name: plural ? 'services' : 'service',
180
214
  property: plural ? 'services' : 'service',
181
- description: plural ? 'Comma-separated service IDs' : 'Service ID',
215
+ description: plural
216
+ ? 'Comma-separated service IDs (buckyos log services)'
217
+ : 'Service ID (buckyos log services)',
182
218
  type: 'string' as const,
183
219
  required: true,
184
220
  },
@@ -187,7 +223,11 @@ function filterOptions(plural: boolean) {
187
223
  description: 'Exact filename in the service log directory',
188
224
  type: 'string' as const,
189
225
  },
190
- { name: 'level', description: 'Exact normalized log level', type: 'string' as const },
226
+ {
227
+ name: 'level',
228
+ description: `Log level: ${LOG_LEVELS.join(', ')} (warn = warning)`,
229
+ type: 'string' as const,
230
+ },
191
231
  { name: 'keyword', description: 'Case-insensitive content filter', type: 'string' as const },
192
232
  { name: 'since', description: 'RFC 3339 lower time boundary', type: 'string' as const },
193
233
  { name: 'until', description: 'RFC 3339 upper time boundary', type: 'string' as const },
@@ -224,7 +264,7 @@ function normalizeFilter(
224
264
  return compact({
225
265
  services,
226
266
  file: inputString(input, 'file'),
227
- level: inputString(input, 'level')?.toLowerCase(),
267
+ level: normalizeLevel(inputString(input, 'level')),
228
268
  keyword: inputString(input, 'keyword'),
229
269
  since: normalizeLogTime(input, 'since'),
230
270
  until: normalizeLogTime(input, 'until'),
@@ -234,6 +274,18 @@ function normalizeFilter(
234
274
  }) as Record<string, unknown> & { services: string[] }
235
275
  }
236
276
 
277
+ function normalizeLevel(value: string | undefined): string | undefined {
278
+ if (!value) return undefined
279
+ const level = value.toLowerCase() === 'warn' ? 'warning' : value.toLowerCase()
280
+ if (!LOG_LEVELS.includes(level)) {
281
+ throw new UsageError(
282
+ 'INVALID_ARGUMENT',
283
+ `level must be one of ${LOG_LEVELS.join(', ')} (warn = warning)`,
284
+ )
285
+ }
286
+ return level
287
+ }
288
+
237
289
  function normalizeLogTime(input: Record<string, unknown>, key: string): string | undefined {
238
290
  const value = inputString(input, key)
239
291
  if (!value) return undefined
@@ -54,6 +54,12 @@ const SHA256_ID = /^sha256:[0-9a-f]{64}$/
54
54
  const OBJECT_ID = /^(?:appdoc|pkg):[0-9a-f]{64}$/
55
55
  const SAFE_GENERATED_FILE = /^[A-Za-z0-9._-]+$/
56
56
  const OBJECT_OUTPUT = { type: 'object' as const, additionalProperties: true }
57
+ const APP_KINDS = ['static-web', 'script', 'docker', 'agent-skills']
58
+ const PATH_KINDS: Record<string, { key: string; category: string; selectorType: string }> = {
59
+ 'static-web': { key: 'web', category: 'web', selectorType: 'static' },
60
+ script: { key: 'script', category: 'dapp', selectorType: 'single' },
61
+ 'agent-skills': { key: 'agent_skills', category: 'agent_skills', selectorType: 'single' },
62
+ }
57
63
 
58
64
  export interface DockerImageInfo {
59
65
  id: string
@@ -201,7 +207,7 @@ export function createPikgModule(dependencies: PikgModuleDependencies = {}): Com
201
207
  name: 'kind',
202
208
  description: 'Initial App kind',
203
209
  type: 'string',
204
- enum: ['static-web', 'script', 'docker'],
210
+ enum: APP_KINDS,
205
211
  },
206
212
  {
207
213
  name: 'source',
@@ -217,7 +223,7 @@ export function createPikgModule(dependencies: PikgModuleDependencies = {}): Com
217
223
  project_dir: { type: 'string', minLength: 1 },
218
224
  name: { type: 'string', minLength: 1 },
219
225
  owner: { type: 'string', minLength: 1 },
220
- kind: { type: 'string', enum: ['static-web', 'script', 'docker'] },
226
+ kind: { type: 'string', enum: APP_KINDS },
221
227
  source: { type: 'string', minLength: 1 },
222
228
  version: { type: 'string', minLength: 1 },
223
229
  app_did: { type: 'string', minLength: 1 },
@@ -234,6 +240,7 @@ export function createPikgModule(dependencies: PikgModuleDependencies = {}): Com
234
240
  examples: [
235
241
  'buckyos pikg init .',
236
242
  'buckyos --non-interactive pikg init . --owner did:bns:root --kind static-web --source ./web/dist',
243
+ 'buckyos --non-interactive pikg init . --owner did:bns:root --kind agent-skills --source ./skills',
237
244
  ],
238
245
  handler: async (ctx, input) => await initCommand(ctx, input, docker),
239
246
  },
@@ -440,11 +447,11 @@ async function initCommand(
440
447
  kind = await requiredPrompt(
441
448
  ctx,
442
449
  canPrompt,
443
- 'App kind (static-web/script/docker): ',
450
+ `App kind (${APP_KINDS.join('/')}): `,
444
451
  'kind',
445
452
  )
446
453
  }
447
- if (!['static-web', 'script', 'docker'].includes(kind)) {
454
+ if (!APP_KINDS.includes(kind)) {
448
455
  throw new UsageError('INVALID_APP_KIND', `invalid App kind: ${kind}`)
449
456
  }
450
457
  let source = optionalInputString(input, 'source')
@@ -474,7 +481,7 @@ async function initCommand(
474
481
  sourceValue = { type: 'docker-image', image: source }
475
482
  sourceSummary = { type: 'docker-image', image: image.canonicalName, image_id: image.id }
476
483
  } else {
477
- subpackageKey = kind === 'static-web' ? 'web' : 'script'
484
+ subpackageKey = PATH_KINDS[kind].key
478
485
  const absoluteSource = resolve(projectDir, source)
479
486
  const persistedSource = isAbsolute(source)
480
487
  ? resolve(source)
@@ -498,9 +505,9 @@ async function initCommand(
498
505
  owner,
499
506
  author: owner,
500
507
  show_name: name,
501
- categories: [kind === 'static-web' ? 'web' : 'dapp'],
508
+ categories: [PATH_KINDS[kind]?.category ?? 'dapp'],
502
509
  permissions: [],
503
- selector_type: kind === 'static-web' ? 'static' : 'single',
510
+ selector_type: PATH_KINDS[kind]?.selectorType ?? 'single',
504
511
  service_config_tips: {},
505
512
  }
506
513
  const pikgMeta = {
@@ -136,6 +136,89 @@ export async function gzipFile(source: string, destination: string): Promise<voi
136
136
  await getHost().gzipFile(source, destination)
137
137
  }
138
138
 
139
+ export interface TarFileEntry {
140
+ path: string
141
+ executable: boolean
142
+ bytes: Uint8Array
143
+ }
144
+
145
+ export async function readTarGz(bytes: Uint8Array, limit: number): Promise<TarFileEntry[]> {
146
+ const tar = await gunzipBytes(bytes, limit)
147
+ const decoder = new TextDecoder('utf-8', { fatal: true })
148
+ const text = (offset: number, length: number) =>
149
+ decoder.decode(tar.subarray(offset, offset + length)).replace(/\0.*$/s, '')
150
+ const files: TarFileEntry[] = []
151
+ const seen = new Set<string>()
152
+ let offset = 0
153
+ while (
154
+ offset + BLOCK_SIZE <= tar.length && tar.subarray(offset, offset + BLOCK_SIZE).some(Boolean)
155
+ ) {
156
+ let raw: string
157
+ let size: number
158
+ let mode: number
159
+ try {
160
+ const prefix = text(offset + 257, 6) === 'ustar' ? text(offset + 345, 155) : ''
161
+ raw = prefix ? `${prefix}/${text(offset, 100)}` : text(offset, 100)
162
+ size = Number.parseInt(text(offset + 124, 12).trim() || '0', 8)
163
+ mode = Number.parseInt(text(offset + 100, 8).trim() || '0', 8)
164
+ } catch {
165
+ throw invalidZip('payload', 'tar header is not valid UTF-8')
166
+ }
167
+ const type = String.fromCharCode(tar[offset + 156] || 0x30)
168
+ const path = raw.replace(/^(?:\.\/)+/, '').replace(/\/$/, '')
169
+ const start = offset + BLOCK_SIZE
170
+ if (!Number.isSafeInteger(size) || size < 0 || start + size > tar.length) {
171
+ throw invalidZip('payload', `tar entry is truncated: ${raw}`)
172
+ }
173
+ offset = start + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE
174
+ if (type === '5' && (path === '' || path === '.')) continue
175
+ if (
176
+ !path || path.startsWith('/') || /^[A-Za-z]:/.test(path) || path.includes('\\') ||
177
+ path.split('/').some((segment) => !segment || segment === '.' || segment === '..')
178
+ ) {
179
+ throw invalidZip('payload', `unsafe tar entry path: ${JSON.stringify(raw)}`)
180
+ }
181
+ if (type === '5') continue
182
+ if (type !== '0') {
183
+ throw invalidZip('payload', `tar entry is not a regular file or directory: ${path}`)
184
+ }
185
+ if (seen.has(path)) throw invalidZip('payload', `duplicate tar entry: ${path}`)
186
+ seen.add(path)
187
+ files.push({ path, executable: (mode & 0o111) !== 0, bytes: tar.slice(start, start + size) })
188
+ }
189
+ return files
190
+ }
191
+
192
+ async function gunzipBytes(bytes: Uint8Array, limit: number): Promise<Uint8Array> {
193
+ const stream = new ReadableStream<Uint8Array>({
194
+ start(controller) {
195
+ controller.enqueue(bytes)
196
+ controller.close()
197
+ },
198
+ }).pipeThrough(
199
+ new DecompressionStream('gzip') as unknown as ReadableWritablePair<Uint8Array, Uint8Array>,
200
+ )
201
+ const chunks: Uint8Array[] = []
202
+ let size = 0
203
+ try {
204
+ for await (const chunk of stream) {
205
+ size += chunk.length
206
+ if (size > limit) throw invalidZip('limits', 'payload expands beyond the size limit')
207
+ chunks.push(chunk)
208
+ }
209
+ } catch (error) {
210
+ if (error instanceof ToolError) throw error
211
+ throw invalidZip('compression', 'payload is not a valid gzip stream')
212
+ }
213
+ const result = new Uint8Array(size)
214
+ let position = 0
215
+ for (const chunk of chunks) {
216
+ result.set(chunk, position)
217
+ position += chunk.length
218
+ }
219
+ return result
220
+ }
221
+
139
222
  export async function writeStoredZip(
140
223
  destination: string,
141
224
  sources: ZipWriteSource[],