@thinger-io/cli-core 0.2.1 → 0.4.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.
@@ -28,6 +28,131 @@ import { mergeHooks } from './resource-hooks.js';
28
28
  * document when the model needs it.
29
29
  */
30
30
 
31
+ /**
32
+ * Types and actions a CLI adds to the catalog (`runCli({ resourceTypes,
33
+ * resourceActions })`): its platform's own things, served by the same
34
+ * tools. A CLI type has no REST path: its functions answer each verb.
35
+ *
36
+ * What reaches those functions comes from the model: `parent` and `id` are
37
+ * checked for their number of segments (and `..`, empty or control
38
+ * characters), but `id`, `filter`, `query` and the body are still untrusted
39
+ * input, to be validated before use.
40
+ *
41
+ * @typedef {object} LocalContext
42
+ * @property {string} type
43
+ * @property {string} [parent]
44
+ * @property {string} [id]
45
+ * @property {string | null} [user]
46
+ * @property {Record<string, any>} [filter] resource_list's filter.
47
+ * @property {Record<string, any>} [query] resource_call's query.
48
+ * @property {boolean} [full]
49
+ * @property {Record<string, any>} state
50
+ *
51
+ * @typedef {object} LocalAction
52
+ * @property {string} description
53
+ * @property {'item' | 'collection' | 'either'} [on] Whether it takes an id: item (default)
54
+ * needs one, collection takes none, either takes it or not.
55
+ * @property {object} [body] JSON schema of its body, shown by resource_describe.
56
+ * @property {(body: any, ctx: LocalContext) => any} run Returns the answer: text,
57
+ * anything JSON, or a whole tool result ({content: [{type, text}], isError}) to flag a
58
+ * failure (only actions can; the verbs answer text or JSON).
59
+ *
60
+ * @typedef {object} LocalType
61
+ * @property {string} description One line, shown in the catalog summary.
62
+ * @property {string} [parent] Type this one hangs off (the server's or another CLI
63
+ * type with an id): its location is the `parent`.
64
+ * @property {string} [id] Name of the item id; without one, a single resource of its parent.
65
+ * @property {string} [notes] Longer guidance returned by resource_describe.
66
+ * @property {object} [schema] JSON schema of a record, the body of create and update.
67
+ * @property {() => Record<string, any> | Promise<Record<string, any>>} [describe] More
68
+ * for resource_describe to show (a reference the type's records follow, say).
69
+ * @property {(ctx: LocalContext) => any[] | Promise<any[]>} [list] The whole list:
70
+ * search and paging are done by the tool.
71
+ * @property {(ctx: LocalContext) => any} [get]
72
+ * @property {(body: any, ctx: LocalContext) => any} [create] Returns a result or a note.
73
+ * @property {(body: any, ctx: LocalContext) => any} [update]
74
+ * @property {(ctx: LocalContext) => any} [delete]
75
+ * @property {Record<string, LocalAction>} [actions]
76
+ */
77
+
78
+ const LOCAL_VERBS = /** @type {const} */ (['list', 'get', 'create', 'update', 'delete']);
79
+ const ACTION_ON = new Set(['item', 'collection', 'either']);
80
+ // eslint-disable-next-line no-control-regex -- control characters are what it looks for
81
+ const CONTROL = /[\x00-\x1f]/;
82
+
83
+ /** An own key of a plain map: never `constructor` or another inherited one. */
84
+ const own = (map, key) =>
85
+ map && typeof key === 'string' && Object.hasOwn(map, key) ? map[key] : undefined;
86
+
87
+ /** A whole MCP tool result, as an action may answer to flag a failure. */
88
+ const isToolResult = (v) =>
89
+ !!v &&
90
+ typeof v === 'object' &&
91
+ Array.isArray(v.content) &&
92
+ v.content.every((c) => c && typeof c === 'object' && typeof c.type === 'string') &&
93
+ Object.keys(v).every((k) => k === 'content' || k === 'isError' || k === '_meta');
94
+
95
+ /**
96
+ * Longest answer a CLI type or action gives (characters of the text sent):
97
+ * a file it reads can be huge. Server types are never cut: a record read to
98
+ * be edited and written back must be whole.
99
+ */
100
+ export const MAX_TEXT = 100_000;
101
+
102
+ /** `text` cut at MAX_TEXT, saying so. */
103
+ function capped(t) {
104
+ t = String(t ?? '');
105
+ if (t.length <= MAX_TEXT) return t;
106
+ return `${t.slice(0, MAX_TEXT)}\n… (cut: ${t.length} characters in total, showing the first ${MAX_TEXT})`;
107
+ }
108
+
109
+ /**
110
+ * Check what a CLI adds against the catalog: a type or an action that
111
+ * clashes with the server's is a mistake, found when the server starts.
112
+ *
113
+ * @param {Record<string, LocalType>} types
114
+ * @param {Record<string, Record<string, LocalAction>>} actions
115
+ */
116
+ export function checkLocal(types, actions) {
117
+ for (const [name, t] of Object.entries(types)) {
118
+ if (CATALOG[name]) throw new Error(`Resource type "${name}" is already a server type`);
119
+ if (!t.description) throw new Error(`Resource type "${name}" needs a description`);
120
+ if (t.parent && !CATALOG[t.parent] && !own(types, t.parent)) {
121
+ throw new Error(`Resource type "${name}": unknown parent "${t.parent}"`);
122
+ }
123
+ const seen = new Set([name]);
124
+ for (let p = t.parent; p && own(types, p); p = types[p].parent) {
125
+ if (seen.has(p)) throw new Error(`Resource type "${name}": its parents loop`);
126
+ if (!types[p].id) throw new Error(`Resource type "${name}": parent "${p}" has no id`);
127
+ seen.add(p);
128
+ }
129
+ if (!LOCAL_VERBS.some((v) => typeof t[v] === 'function') && !t.actions) {
130
+ throw new Error(`Resource type "${name}" has no operations`);
131
+ }
132
+ for (const [a, spec] of Object.entries(t.actions || {})) checkAction(name, a, spec);
133
+ }
134
+ for (const [type, list] of Object.entries(actions)) {
135
+ if (!CATALOG[type] && !types[type]) {
136
+ throw new Error(`Actions for unknown resource type "${type}"`);
137
+ }
138
+ for (const [a, spec] of Object.entries(list)) {
139
+ if (CATALOG[type]?.actions?.[a] || types[type]?.actions?.[a]) {
140
+ throw new Error(`Resource type "${type}" already has an action "${a}"`);
141
+ }
142
+ checkAction(type, a, spec);
143
+ }
144
+ }
145
+ }
146
+
147
+ function checkAction(type, name, spec) {
148
+ if (typeof spec?.run !== 'function' || !spec.description) {
149
+ throw new Error(`Action "${name}" of "${type}" needs a description and run()`);
150
+ }
151
+ if (spec.on !== undefined && !ACTION_ON.has(spec.on)) {
152
+ throw new Error(`Action "${name}" of "${type}": on must be item, collection or either`);
153
+ }
154
+ }
155
+
31
156
  /** `{tool:x}` in catalog notes → full tool names (tests check they exist). */
32
157
  const expandNotes = (notes) => notes.replace(/\{tool:([a-z0-9_]+)\}/g, (_, base) => toolName(base));
33
158
 
@@ -209,7 +334,7 @@ function responseExample(operation) {
209
334
  }
210
335
 
211
336
  /** Which tool runs each verb of a type. */
212
- function operationsOf(type) {
337
+ function operationsOf(ops) {
213
338
  const tools = {
214
339
  list: toolName('resource_list'),
215
340
  get: toolName('resource_get'),
@@ -220,30 +345,115 @@ function operationsOf(type) {
220
345
  clone: toolName('resource_clone'),
221
346
  projects: `${toolName('resource_update')} with {projects: [...]}`,
222
347
  };
223
- return Object.fromEntries(Object.keys(type.ops).map((verb) => [verb, tools[verb]]));
224
- }
225
-
226
- function catalogSummary(names) {
227
- const lines = names.map((name) => {
228
- const t = CATALOG[name];
229
- const parent = t.parent ? ` (parent ${parentHint(name)})` : '';
230
- const id = idNames(t).length ? '' : ' (single, no id)';
231
- const actions = Object.keys(t.actions || {});
232
- const ops =
233
- Object.keys(t.ops).join(' ') + (actions.length ? ` | ${actions.join(' ')}` : '');
234
- return `${name}${parent}${id}: ${expandNotes(t.description)} [${ops}]`;
235
- });
236
- return `Resource types, with their operations and, after |, their actions (run with ${toolName('resource_call')}). ${toolName('resource_describe')} with a type gives its schemas and examples.\n\n${lines.join('\n')}`;
348
+ return Object.fromEntries(Object.keys(ops).map((verb) => [verb, tools[verb]]));
237
349
  }
238
350
 
239
351
  /**
240
- * The tools, for the types this server mode exposes.
352
+ * The tools, over the catalog and what the CLI adds to it.
241
353
  *
242
- * @param {{ hooks?: Record<string, import('./resource-hooks.js').ResourceHooks> }} [opts]
354
+ * @param {{
355
+ * hooks?: Record<string, import('./resource-hooks.js').ResourceHooks>,
356
+ * types?: Record<string, LocalType>,
357
+ * actions?: Record<string, Record<string, LocalAction>>,
358
+ * }} [opts]
243
359
  */
244
- export function resourceTools({ hooks: extraHooks } = {}) {
360
+ export function resourceTools({ hooks: extraHooks, types: local = {}, actions: extra = {} } = {}) {
361
+ checkLocal(local, extra);
245
362
  const hooks = mergeHooks(extraHooks);
246
- const served = new Set(typeNames());
363
+ const served = new Set([...typeNames(), ...Object.keys(local)]);
364
+
365
+ /** Verbs of a type: the catalog's, or the functions a CLI type has. */
366
+ const opsOf = (name) =>
367
+ own(local, name)
368
+ ? Object.fromEntries(
369
+ LOCAL_VERBS.filter((v) => typeof local[name][v] === 'function').map((v) => [
370
+ v,
371
+ 'cli',
372
+ ]),
373
+ )
374
+ : CATALOG[name].ops;
375
+ /** Actions a CLI adds, to its own types or to the server's. */
376
+ const localActionsOf = (name) => ({
377
+ ...(own(local, name)?.actions || {}),
378
+ ...(own(extra, name) || {}),
379
+ });
380
+ const actionNames = (name) => [
381
+ ...Object.keys(CATALOG[name]?.actions || {}),
382
+ ...Object.keys(localActionsOf(name)),
383
+ ];
384
+ const namesWith = (verb) => [...served].filter((n) => verb in opsOf(n));
385
+ /** How the parent of a type is written: `<device>`, `<device>/<bucket>`. */
386
+ const parentOf = (name) => {
387
+ if (!own(local, name)) return parentHint(name);
388
+ const p = local[name].parent;
389
+ return p ? [parentOf(p), idOf(p)].filter(Boolean).join('/') : '';
390
+ };
391
+ function idOf(name) {
392
+ if (!own(local, name)) return idHint(name);
393
+ return local[name].id ? `<${local[name].id}>` : '';
394
+ }
395
+
396
+ /**
397
+ * `parent` (and `id`, when needed) as many segments as the type's
398
+ * location has, none empty, `.`, `..` or with control characters. The
399
+ * id of a CLI type is one value, which may hold "/" (a path).
400
+ */
401
+ function checkLocation(args, { needsId }) {
402
+ const bad = (part) => !part || part === '.' || part === '..' || CONTROL.test(part);
403
+ const segments = (value, hint, what) => {
404
+ if (!hint) return;
405
+ const parts = value === undefined || value === null ? [] : String(value).split('/');
406
+ if (parts.length !== hint.split('/').length || parts.some(bad)) {
407
+ throw inputError(`${what} (${hint}) is required`);
408
+ }
409
+ };
410
+ segments(args.parent, parentOf(args.type), 'parent');
411
+ if (!needsId || !idOf(args.type)) return;
412
+ if (own(local, args.type)) {
413
+ const id = args.id === undefined || args.id === null ? '' : String(args.id);
414
+ if (!id || CONTROL.test(id))
415
+ throw inputError(`id (${idOf(args.type)}) is required`);
416
+ } else {
417
+ segments(args.id, idOf(args.type), 'id');
418
+ }
419
+ }
420
+
421
+ function catalogSummary(names) {
422
+ const lines = names.map((name) => {
423
+ const t = local[name] || CATALOG[name];
424
+ const parent = t.parent ? ` (parent ${parentOf(name)})` : '';
425
+ const id = idOf(name) ? '' : ' (single, no id)';
426
+ const actions = actionNames(name);
427
+ const ops =
428
+ Object.keys(opsOf(name)).join(' ') +
429
+ (actions.length ? ` | ${actions.join(' ')}` : '');
430
+ return `${name}${parent}${id}: ${expandNotes(t.description)} [${ops}]`;
431
+ });
432
+ return `Resource types, with their operations and, after |, their actions (run with ${toolName('resource_call')}). ${toolName('resource_describe')} with a type gives its schemas and examples.\n\n${lines.join('\n')}`;
433
+ }
434
+
435
+ /** Context of a call on a CLI type or action, with its parent and id checked. */
436
+ function localContext(args, { needsId = true, state = {} } = {}) {
437
+ checkLocation(args, { needsId });
438
+ return {
439
+ type: args.type,
440
+ parent: args.parent,
441
+ id: args.id,
442
+ user: args.user,
443
+ filter: args.filter,
444
+ query: args.query,
445
+ full: args.full === true,
446
+ state,
447
+ };
448
+ }
449
+
450
+ /** What a CLI function answered, as tool text. */
451
+ const answer = (value) =>
452
+ value === undefined || value === null || value === ''
453
+ ? undefined
454
+ : typeof value === 'string'
455
+ ? value
456
+ : JSON.stringify(value, null, 2);
247
457
 
248
458
  /**
249
459
  * Validate the type for a verb and build the hook context.
@@ -258,10 +468,16 @@ export function resourceTools({ hooks: extraHooks } = {}) {
258
468
  `Unknown type "${type ?? ''}". ${toolName('resource_describe')} lists them.`,
259
469
  );
260
470
  }
261
- if (!(verb in CATALOG[type].ops)) {
262
- const allowed = Object.keys(CATALOG[type].ops).join(', ');
471
+ if (!(verb in opsOf(type))) {
472
+ const allowed = Object.keys(opsOf(type)).join(', ');
263
473
  throw inputError(`Type "${type}" does not support ${verb} (it supports: ${allowed})`);
264
474
  }
475
+ // CLI types: located before any hook runs; the id of a create may
476
+ // still come from the body.
477
+ if (own(local, type)) {
478
+ if (args.merge) throw inputError(`${type} takes no merge: send the fields to change`);
479
+ checkLocation(args, { needsId: verb !== 'list' && verb !== 'create' });
480
+ }
265
481
  const ctx = {
266
482
  type,
267
483
  parent: args.parent,
@@ -284,10 +500,56 @@ export function resourceTools({ hooks: extraHooks } = {}) {
284
500
  return body;
285
501
  }
286
502
 
503
+ /** Actions as resource_describe shows them: how to run, what they do, their body. */
504
+ function describeActions(actions) {
505
+ return Object.fromEntries(
506
+ Object.entries(actions).map(([a, spec]) => {
507
+ const entry = {
508
+ run: `${toolName('resource_call')} action "${a}"`,
509
+ does: expandNotes(spec.description),
510
+ };
511
+ if (typeof spec.run === 'function') {
512
+ const on = spec.on || 'item';
513
+ if (on !== 'item') entry.id = on === 'collection' ? 'none' : 'optional';
514
+ if (spec.body) entry.body = { schema: spec.body };
515
+ } else if (spec.on === 'collection') {
516
+ entry.id = 'none';
517
+ }
518
+ return [a, entry];
519
+ }),
520
+ );
521
+ }
522
+
523
+ /** resource_describe of a CLI type: everything it says comes from its definition. */
524
+ async function describeLocal(name) {
525
+ const t = local[name];
526
+ const out = { type: name, description: expandNotes(t.description) };
527
+ if (t.parent) out.parent = `${t.parent} (write it as "${parentOf(name)}")`;
528
+ out.id = t.id ? idOf(name) : 'none: a single resource of its parent';
529
+ out.operations = operationsOf(opsOf(name));
530
+ if (t.schema) out.fields = t.schema;
531
+ const actions = localActionsOf(name);
532
+ if (Object.keys(actions).length) out.actions = describeActions(actions);
533
+ if (t.notes) out.notes = expandNotes(t.notes);
534
+ if (t.describe) {
535
+ try {
536
+ Object.assign(out, await t.describe());
537
+ } catch (e) {
538
+ out.reference = `unavailable: ${e instanceof Error ? e.message : String(e)}`;
539
+ }
540
+ }
541
+ return Object.entries(out)
542
+ .map(
543
+ ([key, value]) =>
544
+ `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
545
+ )
546
+ .join('\n');
547
+ }
548
+
287
549
  async function toolDescribe(args) {
288
550
  if (!args.type) {
289
- // The overview call: the types, plus the server's guide to its
290
- // concepts when it publishes one (older servers do not).
551
+ // The overview call: the types, plus the guide to the
552
+ // platform's concepts.
291
553
  const concepts = await readDoc('concepts').then(
292
554
  (d) => d.text,
293
555
  () => '',
@@ -302,23 +564,15 @@ export function resourceTools({ hooks: extraHooks } = {}) {
302
564
  throw inputError(`Unknown type "${args.type}".\n\n${catalogSummary([...served])}`);
303
565
  }
304
566
  const name = args.type;
567
+ if (own(local, name)) return text(capped(await describeLocal(name)));
305
568
  const type = CATALOG[name];
306
569
  const out = { type: name, description: expandNotes(type.description) };
307
570
  const chain = ancestry(name);
308
571
  if (chain.length) out.parent = `${chain.join(' > ')} (write it as "${parentHint(name)}")`;
309
572
  out.id = idNames(type).length ? idHint(name) : 'none: a single resource of its parent';
310
- out.operations = operationsOf(type);
311
- if (type.actions) {
312
- out.actions = Object.fromEntries(
313
- Object.entries(type.actions).map(([a, spec]) => [
314
- a,
315
- {
316
- run: `${toolName('resource_call')} action "${a}"`,
317
- does: expandNotes(spec.description),
318
- },
319
- ]),
320
- );
321
- }
573
+ out.operations = operationsOf(type.ops);
574
+ const allActions = { ...(type.actions || {}), ...localActionsOf(name) };
575
+ if (Object.keys(allActions).length) out.actions = describeActions(allActions);
322
576
  if (type.notes) out.notes = expandNotes(type.notes);
323
577
  if (type.sdk) {
324
578
  out.sdk = await readDoc('dashboard-sdk').then(
@@ -381,7 +635,12 @@ export function resourceTools({ hooks: extraHooks } = {}) {
381
635
  }
382
636
 
383
637
  async function toolList(args) {
384
- const { type } = prepare(args, 'list');
638
+ const { type, ctx } = prepare(args, 'list');
639
+ if (own(local, type)) {
640
+ const lctx = localContext(args, { needsId: false, state: ctx.state });
641
+ const data = (await local[type].list?.(lctx)) ?? [];
642
+ return text(capped(pageOf(type, data, args, true)));
643
+ }
385
644
  const params = { ...(CATALOG[type].listQuery || {}), ...(args.filter || {}) };
386
645
  // Search: the server matches `name` against ids and names (partial,
387
646
  // case-insensitive, as a regular expression: escape it). Lists the
@@ -395,6 +654,14 @@ export function resourceTools({ hooks: extraHooks } = {}) {
395
654
  let data = await sendList(type, args, params);
396
655
  const fields = CATALOG[type].listFields;
397
656
  if (fields && Array.isArray(data)) data = data.map((record) => pick(record, fields));
657
+ return text(pageOf(type, data, args, whole));
658
+ }
659
+
660
+ /**
661
+ * A list as resource_list answers it: searched here when the source
662
+ * answers it whole, and paged here when it ignores count/index.
663
+ */
664
+ function pageOf(type, data, args, whole) {
398
665
  // Lists answered whole: every word must appear in what a record shows.
399
666
  if (args.search && whole && Array.isArray(data)) {
400
667
  const words = String(args.search).toLowerCase().split(/\s+/).filter(Boolean);
@@ -406,29 +673,32 @@ export function resourceTools({ hooks: extraHooks } = {}) {
406
673
  // Some collections ignore count/index and answer everything: page
407
674
  // them here so one call cannot flood the conversation.
408
675
  const pageSize = args.count ?? 50;
409
- if (Array.isArray(data) && data.length > pageSize) {
410
- const from = args.index ?? 0;
676
+ const from = args.index ?? 0;
677
+ if (Array.isArray(data) && (data.length > pageSize || (whole && from > 0))) {
411
678
  const page = data.slice(from, from + pageSize);
412
679
  const next =
413
680
  from + page.length < data.length
414
681
  ? `; pass index: ${from + page.length} for more, or narrow it with filter`
415
682
  : '';
416
- return text(
417
- `${formatList(type, page, Infinity)}\n(${data.length} in total, showing ${from}–${from + page.length - 1}${next})`,
418
- );
683
+ return `${formatList(type, page, Infinity)}\n(${data.length} in total, showing ${from}–${from + page.length - 1}${next})`;
419
684
  }
420
- return text(formatList(type, data, whole ? Infinity : pageSize));
685
+ return formatList(type, data, whole ? Infinity : pageSize);
421
686
  }
422
687
 
423
688
  async function toolGet(args) {
424
689
  const { type, ctx, hook } = prepare(args, 'get');
425
- let record = await send(type, 'get', args);
690
+ const mine = own(local, type);
691
+ let record = mine
692
+ ? await mine.get?.(localContext(args, { state: ctx.state }))
693
+ : await send(type, 'get', args);
426
694
  if (hook.afterGet) record = await hook.afterGet(record, ctx);
427
- return text(JSON.stringify(record, null, 2));
695
+ if (!mine) return text(JSON.stringify(record, null, 2));
696
+ return text(capped(answer(record) ?? `No ${label(type, args)}.`));
428
697
  }
429
698
 
430
699
  async function toolCreate(args) {
431
700
  const { type, ctx, hook } = prepare(args, 'create');
701
+ if (own(local, type)) return createLocal(args, ctx, hook);
432
702
  const t = CATALOG[type];
433
703
  let body = bodyArg(args, 'create');
434
704
  // `projects` is not a field the server takes on create: set it right
@@ -472,9 +742,46 @@ export function resourceTools({ hooks: extraHooks } = {}) {
472
742
  return text(lines.join('\n'));
473
743
  }
474
744
 
745
+ async function createLocal(args, ctx, hook) {
746
+ const t = local[args.type];
747
+ let body = bodyArg(args, 'create');
748
+ if (Array.isArray(body)) throw inputError('body must be a JSON object');
749
+ if (t.id && args.id !== undefined && body[t.id] === undefined)
750
+ body = { [t.id]: args.id, ...body };
751
+ // The hook may set or normalise the id: read it after.
752
+ if (hook.beforeCreate) body = await hook.beforeCreate(body, ctx);
753
+ const id = args.id ?? (t.id ? body?.[t.id] : undefined);
754
+ if (t.id && (id === undefined || id === null || id === '')) {
755
+ throw inputError(`give the ${args.type} id: \`id\`, or the body field "${t.id}"`);
756
+ }
757
+ ctx.id = id;
758
+ const lctx = localContext({ ...args, id }, { state: ctx.state });
759
+ const result = await t.create?.(body, lctx);
760
+ const lines = [`Created ${label(args.type, { parent: args.parent, id })}.`];
761
+ const said = answer(result);
762
+ if (said) lines.push(said);
763
+ const note = hook.afterCreate && (await hook.afterCreate(result, ctx));
764
+ if (note) lines.push('', note);
765
+ return text(lines.join('\n'));
766
+ }
767
+
475
768
  async function toolUpdate(args) {
476
769
  /** @type {import('./catalog.js').Verb} */
477
770
  const verb = args.merge ? 'patch' : 'update';
771
+ if (own(local, args.type)) {
772
+ const { type, ctx, hook } = prepare(args, 'update');
773
+ let body = bodyArg(args, 'update');
774
+ if (Array.isArray(body)) throw inputError('body must be a JSON object');
775
+ if (hook.beforeUpdate) body = await hook.beforeUpdate(body, ctx);
776
+ const result = await local[type].update?.(
777
+ body,
778
+ localContext(args, { state: ctx.state }),
779
+ );
780
+ const said = answer(result);
781
+ return text(
782
+ `Updated ${label(type, args)}: ${Object.keys(body).join(', ')}.${said ? `\n${said}` : ''}`,
783
+ );
784
+ }
478
785
  const { projects, ...fields } = bodyArg(args, 'update');
479
786
  const { type, ctx, hook } = prepare(args, projects !== undefined ? 'projects' : verb);
480
787
  const done = [];
@@ -506,8 +813,13 @@ export function resourceTools({ hooks: extraHooks } = {}) {
506
813
 
507
814
  async function toolDelete(args) {
508
815
  const { type, ctx, hook } = prepare(args, 'delete');
509
- await send(type, 'delete', args);
816
+ const mine = own(local, type);
817
+ const result = mine
818
+ ? await mine.delete?.(localContext(args, { state: ctx.state }))
819
+ : await send(type, 'delete', args);
510
820
  const lines = [`Deleted ${label(type, args)}.`];
821
+ const said = mine && answer(result);
822
+ if (said) lines.push(said);
511
823
  const note = hook.afterDelete && (await hook.afterDelete(ctx));
512
824
  if (note) lines.push(note);
513
825
  return text(lines.join('\n'));
@@ -543,11 +855,36 @@ export function resourceTools({ hooks: extraHooks } = {}) {
543
855
 
544
856
  async function toolCall(args) {
545
857
  const type = args.type;
546
- if (!type || !served.has(type) || !CATALOG[type].actions) {
858
+ const known = typeof type === 'string' && served.has(type);
859
+ const mine = known ? own(localActionsOf(type), args.action) : undefined;
860
+ if (mine) {
861
+ const on = mine.on || 'item';
862
+ if (on === 'collection' && args.id !== undefined) {
863
+ throw inputError(`${args.action} takes no id`);
864
+ }
865
+ const ctx = localContext(args, { needsId: on === 'item' });
866
+ const result = await mine.run(args.body, ctx);
867
+ if (isToolResult(result)) {
868
+ return {
869
+ ...result,
870
+ content: result.content.map((c) =>
871
+ c.type === 'text' ? { ...c, text: capped(c.text) } : c,
872
+ ),
873
+ };
874
+ }
875
+ const said = answer(result);
876
+ return text(capped(said ?? `Ran ${args.action} on ${label(type, args)}.`));
877
+ }
878
+ if (!known || !actionNames(type).length) {
547
879
  throw inputError(
548
880
  `Type "${type ?? ''}" has no actions. ${toolName('resource_describe')} lists them after |.`,
549
881
  );
550
882
  }
883
+ if (!own(CATALOG[type]?.actions, args.action)) {
884
+ throw inputError(
885
+ `Type "${type}" has no action "${args.action}" (it has: ${actionNames(type).join(', ')})`,
886
+ );
887
+ }
551
888
  const { method, url, query } = actionRequest(type, args.action, args);
552
889
  const spec = CATALOG[type].actions?.[args.action];
553
890
  const started = Date.now();
@@ -585,7 +922,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
585
922
 
586
923
  const typeParam = (verb) => ({
587
924
  type: 'string',
588
- enum: typeNames({ verb }),
925
+ enum: verb ? namesWith(verb) : [...served],
589
926
  description: 'Resource type (see {tool:resource_describe}).',
590
927
  });
591
928
  const common = {
@@ -609,7 +946,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
609
946
  "Describe a resource type: its parent and id, the operations it supports and, from this server's API, the request schemas and examples for create, update and clone. Call it before writing a type for the first time. Without `type`: all types and a guide to the platform's concepts (start here).",
610
947
  inputSchema: {
611
948
  type: 'object',
612
- properties: { type: { ...typeParam(), enum: [...served] } },
949
+ properties: { type: typeParam() },
613
950
  required: [],
614
951
  },
615
952
  handler: toolDescribe,
@@ -650,7 +987,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
650
987
  full: {
651
988
  type: 'boolean',
652
989
  description:
653
- 'Plugin catalog manifests only: also include the product code and the console dashboard (large).',
990
+ 'Plugin catalog manifests: also include the product code and the console dashboard (large). Types that take it say so in their notes.',
654
991
  },
655
992
  user: common.user,
656
993
  },
@@ -742,7 +1079,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
742
1079
  properties: {
743
1080
  type: {
744
1081
  type: 'string',
745
- enum: Object.keys(CATALOG).filter((n) => CATALOG[n].actions),
1082
+ enum: [...served].filter((n) => actionNames(n).length),
746
1083
  description: 'Resource type.',
747
1084
  },
748
1085
  action: { type: 'string', description: 'Action name, e.g. "verify".' },
package/lib/tgz.js ADDED
@@ -0,0 +1,66 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import zlib from 'node:zlib';
5
+
6
+ /**
7
+ * Extract an npm package tarball (.tgz) into a folder, without a `tar`
8
+ * binary: only regular files and folders, every path kept inside `dest`.
9
+ * npm packs ustar archives, with pax headers for long paths.
10
+ *
11
+ * @param {Buffer} tgz
12
+ * @param {string} dest Folder to extract into (created).
13
+ * @param {{ strip?: number }} [opts] Leading path segments to drop (1 for npm's `package/`).
14
+ */
15
+ export function extractTgz(tgz, dest, { strip = 0 } = {}) {
16
+ let tar;
17
+ try {
18
+ tar = zlib.gunzipSync(tgz);
19
+ } catch {
20
+ throw new Error('not a .tgz archive');
21
+ }
22
+ const root = path.resolve(dest);
23
+ fs.mkdirSync(root, { recursive: true });
24
+ const field = (buf, start, length) =>
25
+ buf.toString('utf8', start, start + length).replace(/\0.*$/s, '');
26
+ let offset = 0;
27
+ /** @type {string | null} */
28
+ let longName = null;
29
+ while (offset + 512 <= tar.length) {
30
+ const header = tar.subarray(offset, offset + 512);
31
+ if (header.every((b) => b === 0)) break;
32
+ const size = parseInt(field(header, 124, 12).trim() || '0', 8);
33
+ if (!Number.isFinite(size) || size < 0) throw new Error('corrupt tar header');
34
+ const type = String.fromCharCode(header[156] || 48);
35
+ const prefix = field(header, 345, 155);
36
+ const name =
37
+ longName ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100));
38
+ longName = null;
39
+ const body = tar.subarray(offset + 512, offset + 512 + size);
40
+ offset += 512 + Math.ceil(size / 512) * 512;
41
+
42
+ if (type === 'x') {
43
+ // pax extended header: "<len> path=<value>\n" records for the next entry
44
+ const match = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(body.toString('utf8'));
45
+ if (match) longName = match[1];
46
+ continue;
47
+ }
48
+ if (type === 'L') {
49
+ longName = body.toString('utf8').replace(/\0.*$/s, '');
50
+ continue;
51
+ }
52
+ if (type !== '0' && type !== '5') continue; // links, devices… are skipped
53
+ const parts = name.split('/').filter(Boolean).slice(strip);
54
+ if (!parts.length) continue;
55
+ if (parts.some((p) => p === '..' || p.includes('\\') || p.includes(':'))) {
56
+ throw new Error(`unsafe path in archive: ${name}`);
57
+ }
58
+ const target = path.join(root, ...parts);
59
+ if (!target.startsWith(root + path.sep)) throw new Error(`unsafe path in archive: ${name}`);
60
+ if (type === '5') fs.mkdirSync(target, { recursive: true });
61
+ else {
62
+ fs.mkdirSync(path.dirname(target), { recursive: true });
63
+ fs.writeFileSync(target, body);
64
+ }
65
+ }
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinger-io/cli-core",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Shared core of the Thinger.io CLIs and their MCP servers: API client, auth, profiles, generic commands and MCP tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "lib",
11
+ "docs",
11
12
  "openapi",
12
13
  "CHANGELOG.md",
13
14
  "LICENSE",
@@ -38,6 +39,7 @@
38
39
  "dependencies": {
39
40
  "@inquirer/prompts": "^8.4.1",
40
41
  "@modelcontextprotocol/sdk": "^1.29.0",
42
+ "@thinger-io/dashboard-sdk": "^0.3.0",
41
43
  "axios": "^1.10.0",
42
44
  "chalk": "^5.4.1",
43
45
  "commander": "^14.0.0",