@pikku/inspector 0.12.112 → 0.12.114

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.
@@ -8,8 +8,10 @@ import type { InspectorState, InspectorLogger } from '../types.js';
8
8
  */
9
9
  export declare function addPikkuStudio(node: ts.Node, state: InspectorState, logger: InspectorLogger): void;
10
10
  /**
11
- * The checks on `definePlugin` that need the whole package: every title is a
12
- * key in the addon's `messages/en.json` (a key missing from another locale is
13
- * a warning), and every nav badge names one of the addon's functions.
11
+ * The checks on `definePlugin` that need the whole package: every title, and
12
+ * every agent's frontmatter title and description, is a key in the addon's
13
+ * `messages/en.json` (a key missing from another locale is a warning), every
14
+ * nav badge names one of the addon's functions, and a `files` list in the
15
+ * package.json publishes the agents/ and skills/ the addon has.
14
16
  */
15
17
  export declare function validatePikkuStudio(logger: InspectorLogger, state: InspectorState): void;
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
3
3
  import { dirname, join } from 'path';
4
4
  import { ErrorCode } from '../error-codes.js';
5
5
  const SCOPES = ['studio', 'team', 'project'];
@@ -110,6 +110,8 @@ const readScreen = (element, fail) => {
110
110
  let scopes;
111
111
  let component;
112
112
  let app;
113
+ let agent;
114
+ let defaultChat;
113
115
  let ok = true;
114
116
  for (const field of element.properties) {
115
117
  const key = propName(field);
@@ -136,6 +138,10 @@ const readScreen = (element, fail) => {
136
138
  read = replaces = booleanLiteral(value);
137
139
  else if (key === 'needsApp')
138
140
  read = needsApp = booleanLiteral(value);
141
+ else if (key === 'defaultChat')
142
+ read = defaultChat = booleanLiteral(value);
143
+ else if (key === 'agent')
144
+ read = agent = ts.isStringLiteral(value) ? value.text : undefined;
139
145
  else if (key === 'layout')
140
146
  read = layout = oneOf(value, ['bare']);
141
147
  else if (key === 'scopes') {
@@ -182,6 +188,8 @@ const readScreen = (element, fail) => {
182
188
  ...(needsApp !== undefined ? { needsApp } : {}),
183
189
  ...(layout ? { layout } : {}),
184
190
  ...(scopes ? { scopes } : {}),
191
+ ...(agent !== undefined ? { agent } : {}),
192
+ ...(defaultChat !== undefined ? { defaultChat } : {}),
185
193
  ...(component ? { component } : { app: app }),
186
194
  };
187
195
  };
@@ -195,7 +203,8 @@ export function addPikkuStudio(node, state, logger) {
195
203
  if (!ts.isCallExpression(node))
196
204
  return;
197
205
  const { expression, arguments: args } = node;
198
- if (!ts.isIdentifier(expression) || expression.text !== 'definePlugin' && expression.text !== 'pikkuStudio')
206
+ if (!ts.isIdentifier(expression) ||
207
+ (expression.text !== 'definePlugin' && expression.text !== 'pikkuStudio'))
199
208
  return;
200
209
  const [firstArg] = args;
201
210
  if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
@@ -259,6 +268,55 @@ const packageRoot = (file) => {
259
268
  dir = parent;
260
269
  }
261
270
  };
271
+ const AGENT_NAME = /^[a-z][a-z0-9-]*$/;
272
+ const STUDIO_AGENT = /^studio:[a-z][a-z0-9-]*$/;
273
+ /** The text of a file that exists and is not blank, else undefined. */
274
+ const readNonEmpty = (file) => {
275
+ try {
276
+ if (!statSync(file).isFile())
277
+ return undefined;
278
+ const text = readFileSync(file, 'utf-8');
279
+ return text.trim() === '' ? undefined : text;
280
+ }
281
+ catch {
282
+ return undefined;
283
+ }
284
+ };
285
+ /** The `key: value` lines of a leading `---` block; undefined when there is none. */
286
+ const readFrontmatter = (text) => {
287
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
288
+ if (!match)
289
+ return undefined;
290
+ const fields = {};
291
+ for (const line of match[1].split(/\r?\n/)) {
292
+ const pair = /^([A-Za-z][\w-]*):\s*(.*?)\s*$/.exec(line);
293
+ if (!pair)
294
+ continue;
295
+ fields[pair[1]] = pair[2].replace(/^(['"])(.*)\1$/, '$2');
296
+ }
297
+ return fields;
298
+ };
299
+ /**
300
+ * The package's `files` list, or undefined when it has none: what npm then
301
+ * publishes depends on `.npmignore` or `.gitignore`, which are not read here.
302
+ */
303
+ const packageFiles = (root) => {
304
+ const files = readCatalog(join(root, 'package.json'))?.files;
305
+ return Array.isArray(files)
306
+ ? files.filter((entry) => typeof entry === 'string')
307
+ : undefined;
308
+ };
309
+ const filesInclude = (files, dir) => files.some((entry) => entry === dir || entry === `${dir}/` || entry.startsWith(`${dir}/`));
310
+ /** Whether the package has a `skills/<name>/SKILL.md`. */
311
+ const hasSkills = (root) => {
312
+ const dir = join(root, 'skills');
313
+ try {
314
+ return readdirSync(dir, { withFileTypes: true }).some((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, 'SKILL.md')));
315
+ }
316
+ catch {
317
+ return false;
318
+ }
319
+ };
262
320
  const readCatalog = (file) => {
263
321
  try {
264
322
  return JSON.parse(readFileSync(file, 'utf-8'));
@@ -268,40 +326,101 @@ const readCatalog = (file) => {
268
326
  }
269
327
  };
270
328
  /**
271
- * The checks on `definePlugin` that need the whole package: every title is a
272
- * key in the addon's `messages/en.json` (a key missing from another locale is
273
- * a warning), and every nav badge names one of the addon's functions.
329
+ * The checks on `definePlugin` that need the whole package: every title, and
330
+ * every agent's frontmatter title and description, is a key in the addon's
331
+ * `messages/en.json` (a key missing from another locale is a warning), every
332
+ * nav badge names one of the addon's functions, and a `files` list in the
333
+ * package.json publishes the agents/ and skills/ the addon has.
274
334
  */
275
335
  export function validatePikkuStudio(logger, state) {
276
336
  const studio = state.studio;
277
337
  if (!studio)
278
338
  return;
279
- const keys = [studio.title, ...studio.screens.map((screen) => screen.title)];
339
+ const keys = new Set([
340
+ studio.title,
341
+ ...studio.screens.map((screen) => screen.title),
342
+ ]);
280
343
  const root = packageRoot(studio.file);
344
+ const files = root ? packageFiles(root) : undefined;
281
345
  const messagesDir = root ? join(root, 'messages') : undefined;
282
346
  const en = messagesDir ? readCatalog(join(messagesDir, 'en.json')) : undefined;
283
- for (const key of new Set(keys)) {
347
+ for (const key of keys) {
284
348
  if (!en || !Object.hasOwn(en, key)) {
285
349
  logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `definePlugin uses the message key '${key}', which is missing from the addon's messages/en.json`);
286
350
  }
287
351
  }
352
+ for (const screen of studio.screens) {
353
+ const agent = screen.agent;
354
+ if (agent !== undefined) {
355
+ let agentValid = true;
356
+ const invalid = (reason) => {
357
+ agentValid = false;
358
+ logger.critical(ErrorCode.STUDIO_SCREEN_AGENT_INVALID, `Screen '${screen.path}' has the agent '${agent}', ${reason}`);
359
+ };
360
+ if (agent.startsWith('studio:')) {
361
+ // An agent Studio ships: pikku cannot see it, so only the format is checked.
362
+ if (!STUDIO_AGENT.test(agent)) {
363
+ invalid("which is not a valid agent: use a name for an agent the addon ships (lowercase letters, digits and hyphens, starting with a letter) or 'studio:<name>' for one Studio ships");
364
+ }
365
+ }
366
+ else if (!AGENT_NAME.test(agent)) {
367
+ invalid("which is not a valid agent: use a name for an agent the addon ships (lowercase letters, digits and hyphens, starting with a letter) or 'studio:<name>' for one Studio ships");
368
+ }
369
+ else {
370
+ const text = root
371
+ ? readNonEmpty(join(root, 'agents', `${agent}.md`))
372
+ : undefined;
373
+ const fields = text === undefined ? undefined : readFrontmatter(text);
374
+ if (text === undefined) {
375
+ invalid(`but the addon has no non-empty agents/${agent}.md`);
376
+ }
377
+ else if (!fields) {
378
+ invalid(`whose agents/${agent}.md has no frontmatter; it needs a title and a description`);
379
+ }
380
+ else {
381
+ for (const field of ['title', 'description']) {
382
+ const key = fields[field];
383
+ if (!key) {
384
+ invalid(`whose agents/${agent}.md frontmatter has no ${field}; it must be a message key`);
385
+ continue;
386
+ }
387
+ keys.add(key);
388
+ if (!en || !Object.hasOwn(en, key)) {
389
+ logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `definePlugin uses the message key '${key}', which is missing from the addon's messages/en.json`);
390
+ }
391
+ else if (field === 'title')
392
+ screen.agentTitle = key;
393
+ else
394
+ screen.agentDescription = key;
395
+ }
396
+ }
397
+ }
398
+ if (agentValid &&
399
+ !agent.startsWith('studio:') &&
400
+ files &&
401
+ !filesInclude(files, 'agents')) {
402
+ logger.critical(ErrorCode.STUDIO_PACKAGE_FILES_MISSING, `Screen '${screen.path}' has the agent '${agent}', but package.json "files" does not list agents/; add "agents" to it so the published addon ships the agent`);
403
+ }
404
+ }
405
+ const badge = screen.nav?.badge;
406
+ if (badge !== undefined && !state.functions.meta[badge]) {
407
+ logger.critical(ErrorCode.STUDIO_BADGE_FUNCTION_NOT_FOUND, `Screen '${screen.path}' has the nav badge '${badge}', which names no function in this addon`);
408
+ }
409
+ }
410
+ if (root && files && !filesInclude(files, 'skills') && hasSkills(root)) {
411
+ logger.critical(ErrorCode.STUDIO_PACKAGE_FILES_MISSING, `The addon ships skills/ but package.json "files" does not list it; add "skills" to it so the published addon ships its skills`);
412
+ }
288
413
  if (messagesDir && existsSync(messagesDir)) {
289
414
  const locales = readdirSync(messagesDir)
290
415
  .filter((name) => name.endsWith('.json') && name !== 'en.json')
291
416
  .sort();
292
417
  for (const name of locales) {
293
418
  const catalog = readCatalog(join(messagesDir, name)) ?? {};
294
- for (const key of new Set(keys)) {
419
+ for (const key of keys) {
295
420
  if (!Object.hasOwn(catalog, key)) {
296
421
  logger.warn(`definePlugin uses the message key '${key}', which is missing from the addon's messages/${name}`);
297
422
  }
298
423
  }
299
424
  }
300
425
  }
301
- for (const screen of studio.screens) {
302
- const badge = screen.nav?.badge;
303
- if (badge !== undefined && !state.functions.meta[badge]) {
304
- logger.critical(ErrorCode.STUDIO_BADGE_FUNCTION_NOT_FOUND, `Screen '${screen.path}' has the nav badge '${badge}', which names no function in this addon`);
305
- }
306
- }
307
426
  }
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript';
2
2
  import type { InspectorState, InspectorLogger } from '../types.js';
3
+ export declare function parseStringArray(node: ts.Expression): string[] | undefined;
3
4
  /**
4
5
  * Detect wireAddon({ name: '...', package: '...' }) call expressions and
5
6
  * populate state.rpc.wireAddonDeclarations and state.rpc.usedAddons.
@@ -1,6 +1,6 @@
1
1
  import * as ts from 'typescript';
2
2
  import { ErrorCode } from '../error-codes.js';
3
- function parseStringArray(node) {
3
+ export function parseStringArray(node) {
4
4
  if (!ts.isArrayLiteralExpression(node))
5
5
  return undefined;
6
6
  const values = [];
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript';
2
2
  import { ErrorCode } from '../error-codes.js';
3
+ import { parseStringArray } from './add-wire-addon.js';
3
4
  import { parseRemoteAddonPackage } from '../utils/remote-addon-sdk.js';
4
5
  /**
5
6
  * Detect wireRemoteAddon({ name: '...', package: '...' }) call expressions and
@@ -27,6 +28,7 @@ export function addWireRemoteAddon(node, state, logger) {
27
28
  let hasAuth = false;
28
29
  let authCredentialId;
29
30
  let authSecretId;
31
+ let local;
30
32
  for (const prop of firstArg.properties) {
31
33
  if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
32
34
  continue;
@@ -40,6 +42,42 @@ export function addWireRemoteAddon(node, state, logger) {
40
42
  pkg = prop.initializer.text;
41
43
  }
42
44
  }
45
+ else if (key === 'local' &&
46
+ ts.isObjectLiteralExpression(prop.initializer)) {
47
+ let localPackage;
48
+ let ui;
49
+ let contributors;
50
+ for (const localProp of prop.initializer.properties) {
51
+ if (!ts.isPropertyAssignment(localProp) ||
52
+ !ts.isIdentifier(localProp.name))
53
+ continue;
54
+ const localKey = localProp.name.text;
55
+ if (localKey === 'package' &&
56
+ ts.isStringLiteral(localProp.initializer)) {
57
+ localPackage = localProp.initializer.text;
58
+ }
59
+ else if (localKey === 'ui' &&
60
+ localProp.initializer.kind === ts.SyntaxKind.TrueKeyword) {
61
+ ui = true;
62
+ }
63
+ else if (localKey === 'contributors') {
64
+ contributors = parseStringArray(localProp.initializer);
65
+ if (contributors === undefined) {
66
+ logger.critical(ErrorCode.ADDON_CONTRIBUTORS_NOT_STATIC, `wireRemoteAddon's local.contributors must be an array of string literals, got: ${localProp.initializer.getText()}`);
67
+ }
68
+ }
69
+ }
70
+ if (localPackage) {
71
+ local = {
72
+ package: localPackage,
73
+ ...(ui ? { ui } : {}),
74
+ ...(contributors ? { contributors } : {}),
75
+ };
76
+ }
77
+ else {
78
+ logger.critical(ErrorCode.REMOTE_ADDON_PACKAGE_NOT_STATIC, `wireRemoteAddon's local.package must be a string literal, got: ${prop.initializer.getText()}`);
79
+ }
80
+ }
43
81
  else if (key === 'auth' &&
44
82
  ts.isObjectLiteralExpression(prop.initializer)) {
45
83
  hasAuth = true;
@@ -83,6 +121,7 @@ export function addWireRemoteAddon(node, state, logger) {
83
121
  hasAuth,
84
122
  authCredentialId,
85
123
  authSecretId,
124
+ ...(local ? { local } : {}),
86
125
  });
87
126
  state.rpc.usedAddons.add(name);
88
127
  state.rpc.wireAddonFiles.add(node.getSourceFile().fileName);
@@ -57,6 +57,8 @@ export declare enum ErrorCode {
57
57
  ADDON_CONTRIBUTORS_NOT_STATIC = "PKU362",
58
58
  ADDON_CONTRIBUTOR_UNKNOWN = "PKU363",
59
59
  ADDON_LEGACY_CONTRIBUTORS = "PKU364",
60
+ STUDIO_SCREEN_AGENT_INVALID = "PKU365",
61
+ STUDIO_PACKAGE_FILES_MISSING = "PKU366",
60
62
  DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
61
63
  DB_JSON_COLUMN_UNTYPED = "PKU481",
62
64
  DB_FORMAT_ON_NON_STRING = "PKU482",
@@ -60,6 +60,8 @@ export var ErrorCode;
60
60
  ErrorCode["ADDON_CONTRIBUTORS_NOT_STATIC"] = "PKU362";
61
61
  ErrorCode["ADDON_CONTRIBUTOR_UNKNOWN"] = "PKU363";
62
62
  ErrorCode["ADDON_LEGACY_CONTRIBUTORS"] = "PKU364";
63
+ ErrorCode["STUDIO_SCREEN_AGENT_INVALID"] = "PKU365";
64
+ ErrorCode["STUDIO_PACKAGE_FILES_MISSING"] = "PKU366";
63
65
  // Database schema codegen warnings
64
66
  ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
65
67
  ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
package/dist/types.d.ts CHANGED
@@ -548,6 +548,12 @@ export type StudioScreenMeta = {
548
548
  needsApp?: boolean;
549
549
  layout?: 'bare';
550
550
  scopes?: string[];
551
+ agent?: string;
552
+ /** Message key from the agent file's frontmatter, filled by validation. */
553
+ agentTitle?: string;
554
+ /** Message key from the agent file's frontmatter, filled by validation. */
555
+ agentDescription?: string;
556
+ defaultChat?: boolean;
551
557
  } & ({
552
558
  component: string;
553
559
  app?: undefined;
@@ -767,6 +773,16 @@ export interface InspectorState {
767
773
  auth?: boolean;
768
774
  tags?: string[];
769
775
  scopes?: string[];
776
+ /**
777
+ * wireRemoteAddon: a package installed in this project that carries the
778
+ * screens and contributors for this name, when they live apart from the
779
+ * contract package. `ui` and `contributors` are read from it.
780
+ */
781
+ local?: {
782
+ package: string;
783
+ ui?: boolean;
784
+ contributors?: string[];
785
+ };
770
786
  /** True when declared via `wireRemoteAddon` — import the addon's `.remote.gen` map, not `.internal.gen` */
771
787
  remote?: boolean;
772
788
  /**
@@ -279,6 +279,27 @@ const recordDeclaredUses = (logger, namespace, decl, dirs) => {
279
279
  : `${decl.package} needs ${needed}, but wireAddon('${namespace}') does not map it. Wire ${needed} and add uses: { '${needed}': '<name>' } to this wireAddon.`);
280
280
  }
281
281
  };
282
+ const recordAddonScreens = async (logger, state, require, namespace, packageName, scopes) => {
283
+ let manifest;
284
+ try {
285
+ manifest = await loadStudioMeta(require, packageName);
286
+ }
287
+ catch (e) {
288
+ logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but the screens manifest of ${packageName} could not be read: ${e instanceof Error ? e.message : e}`);
289
+ return;
290
+ }
291
+ if (!manifest || manifest.screens.length === 0) {
292
+ logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but ${packageName} declares no screens. Add a definePlugin call to the package, or drop ui.`);
293
+ return;
294
+ }
295
+ ;
296
+ (state.addonStudio ??= {})[namespace] = {
297
+ ...manifest,
298
+ scopes: [
299
+ ...new Set([...(manifest.scopes ?? []), ...(scopes ?? [])]),
300
+ ].sort(),
301
+ };
302
+ };
282
303
  export async function loadAddonFunctionsMeta(logger, state) {
283
304
  const { wireAddonDeclarations } = state.rpc;
284
305
  if (wireAddonDeclarations.size === 0)
@@ -291,6 +312,16 @@ export async function loadAddonFunctionsMeta(logger, state) {
291
312
  if (decl.range) {
292
313
  await loadRemoteAddonSdkMeta(state, namespace, decl.package, decl.range);
293
314
  }
315
+ if (decl.local) {
316
+ const dirs = addonResolutionDirs(state.rootDir, decl.file);
317
+ const require = createAddonResolver(dirs);
318
+ if (decl.local.contributors && decl.local.contributors.length > 0) {
319
+ await recordOptedInContributors(logger, state, require, namespace, decl.local.package, decl.local.contributors);
320
+ }
321
+ if (decl.local.ui) {
322
+ await recordAddonScreens(logger, state, require, namespace, decl.local.package, undefined);
323
+ }
324
+ }
294
325
  continue;
295
326
  }
296
327
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
@@ -304,30 +335,7 @@ export async function loadAddonFunctionsMeta(logger, state) {
304
335
  logger.critical(ErrorCode.ADDON_LEGACY_CONTRIBUTORS, `${decl.package} still declares "pikku.contributors" in its package.json, which is no longer read. Remove it, register the contributors with defineContributors in the addon's source, and opt in with wireAddon('${namespace}', { contributors: [...] }). See docs/contributors.md.`);
305
336
  }
306
337
  if (decl.ui) {
307
- let manifest;
308
- let unreadable = false;
309
- try {
310
- manifest = await loadStudioMeta(require, decl.package);
311
- }
312
- catch (e) {
313
- logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but the screens manifest of ${decl.package} could not be read: ${e instanceof Error ? e.message : e}`);
314
- unreadable = true;
315
- }
316
- if (unreadable) {
317
- // reported above; the addon's functions and scopes still load
318
- }
319
- else if (!manifest || manifest.screens.length === 0) {
320
- logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but ${decl.package} declares no screens. Add a definePlugin call to the package, or drop ui.`);
321
- }
322
- else {
323
- ;
324
- (state.addonStudio ??= {})[namespace] = {
325
- ...manifest,
326
- scopes: [
327
- ...new Set([...(manifest.scopes ?? []), ...(decl.scopes ?? [])]),
328
- ].sort(),
329
- };
330
- }
338
+ await recordAddonScreens(logger, state, require, namespace, decl.package, decl.scopes);
331
339
  }
332
340
  // Verbose first. `description` is one of the fields stripped from the
333
341
  // minimal copy, and it is what an addon's function is offered to a model
@@ -199,6 +199,11 @@ export interface SerializableInspectorState {
199
199
  file?: string;
200
200
  rpcEndpoint?: string;
201
201
  remote?: boolean;
202
+ local?: {
203
+ package: string;
204
+ ui?: boolean;
205
+ contributors?: string[];
206
+ };
202
207
  hasAuth?: boolean;
203
208
  authCredentialId?: string;
204
209
  authSecretId?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.112",
3
+ "version": "0.12.114",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -70,10 +70,10 @@
70
70
  "zod": "^4.6.7"
71
71
  },
72
72
  "peerDependencies": {
73
- "@pikku/core": "^0.12.148"
73
+ "@pikku/core": "^0.12.151"
74
74
  },
75
75
  "devDependencies": {
76
- "@pikku/core": "^0.12.148",
76
+ "@pikku/core": "^0.12.151",
77
77
  "@types/node": "^26.6.5"
78
78
  },
79
79
  "engines": {