@pikku/inspector 0.12.113 → 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;
@@ -111,6 +111,7 @@ const readScreen = (element, fail) => {
111
111
  let component;
112
112
  let app;
113
113
  let agent;
114
+ let defaultChat;
114
115
  let ok = true;
115
116
  for (const field of element.properties) {
116
117
  const key = propName(field);
@@ -137,6 +138,8 @@ const readScreen = (element, fail) => {
137
138
  read = replaces = booleanLiteral(value);
138
139
  else if (key === 'needsApp')
139
140
  read = needsApp = booleanLiteral(value);
141
+ else if (key === 'defaultChat')
142
+ read = defaultChat = booleanLiteral(value);
140
143
  else if (key === 'agent')
141
144
  read = agent = ts.isStringLiteral(value) ? value.text : undefined;
142
145
  else if (key === 'layout')
@@ -186,6 +189,7 @@ const readScreen = (element, fail) => {
186
189
  ...(layout ? { layout } : {}),
187
190
  ...(scopes ? { scopes } : {}),
188
191
  ...(agent !== undefined ? { agent } : {}),
192
+ ...(defaultChat !== undefined ? { defaultChat } : {}),
189
193
  ...(component ? { component } : { app: app }),
190
194
  };
191
195
  };
@@ -292,6 +296,27 @@ const readFrontmatter = (text) => {
292
296
  }
293
297
  return fields;
294
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
+ };
295
320
  const readCatalog = (file) => {
296
321
  try {
297
322
  return JSON.parse(readFileSync(file, 'utf-8'));
@@ -301,40 +326,37 @@ const readCatalog = (file) => {
301
326
  }
302
327
  };
303
328
  /**
304
- * The checks on `definePlugin` that need the whole package: every title is a
305
- * key in the addon's `messages/en.json` (a key missing from another locale is
306
- * 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.
307
334
  */
308
335
  export function validatePikkuStudio(logger, state) {
309
336
  const studio = state.studio;
310
337
  if (!studio)
311
338
  return;
312
- 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
+ ]);
313
343
  const root = packageRoot(studio.file);
344
+ const files = root ? packageFiles(root) : undefined;
314
345
  const messagesDir = root ? join(root, 'messages') : undefined;
315
346
  const en = messagesDir ? readCatalog(join(messagesDir, 'en.json')) : undefined;
316
- for (const key of new Set(keys)) {
347
+ for (const key of keys) {
317
348
  if (!en || !Object.hasOwn(en, key)) {
318
349
  logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `definePlugin uses the message key '${key}', which is missing from the addon's messages/en.json`);
319
350
  }
320
351
  }
321
- if (messagesDir && existsSync(messagesDir)) {
322
- const locales = readdirSync(messagesDir)
323
- .filter((name) => name.endsWith('.json') && name !== 'en.json')
324
- .sort();
325
- for (const name of locales) {
326
- const catalog = readCatalog(join(messagesDir, name)) ?? {};
327
- for (const key of new Set(keys)) {
328
- if (!Object.hasOwn(catalog, key)) {
329
- logger.warn(`definePlugin uses the message key '${key}', which is missing from the addon's messages/${name}`);
330
- }
331
- }
332
- }
333
- }
334
352
  for (const screen of studio.screens) {
335
353
  const agent = screen.agent;
336
354
  if (agent !== undefined) {
337
- const invalid = (reason) => logger.critical(ErrorCode.STUDIO_SCREEN_AGENT_INVALID, `Screen '${screen.path}' has the agent '${agent}', ${reason}`);
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
+ };
338
360
  if (agent.startsWith('studio:')) {
339
361
  // An agent Studio ships: pikku cannot see it, so only the format is checked.
340
362
  if (!STUDIO_AGENT.test(agent)) {
@@ -360,8 +382,10 @@ export function validatePikkuStudio(logger, state) {
360
382
  const key = fields[field];
361
383
  if (!key) {
362
384
  invalid(`whose agents/${agent}.md frontmatter has no ${field}; it must be a message key`);
385
+ continue;
363
386
  }
364
- else if (!en || !Object.hasOwn(en, key)) {
387
+ keys.add(key);
388
+ if (!en || !Object.hasOwn(en, key)) {
365
389
  logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `definePlugin uses the message key '${key}', which is missing from the addon's messages/en.json`);
366
390
  }
367
391
  else if (field === 'title')
@@ -371,10 +395,32 @@ export function validatePikkuStudio(logger, state) {
371
395
  }
372
396
  }
373
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
+ }
374
404
  }
375
405
  const badge = screen.nav?.badge;
376
406
  if (badge !== undefined && !state.functions.meta[badge]) {
377
407
  logger.critical(ErrorCode.STUDIO_BADGE_FUNCTION_NOT_FOUND, `Screen '${screen.path}' has the nav badge '${badge}', which names no function in this addon`);
378
408
  }
379
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
+ }
413
+ if (messagesDir && existsSync(messagesDir)) {
414
+ const locales = readdirSync(messagesDir)
415
+ .filter((name) => name.endsWith('.json') && name !== 'en.json')
416
+ .sort();
417
+ for (const name of locales) {
418
+ const catalog = readCatalog(join(messagesDir, name)) ?? {};
419
+ for (const key of keys) {
420
+ if (!Object.hasOwn(catalog, key)) {
421
+ logger.warn(`definePlugin uses the message key '${key}', which is missing from the addon's messages/${name}`);
422
+ }
423
+ }
424
+ }
425
+ }
380
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);
@@ -58,6 +58,7 @@ export declare enum ErrorCode {
58
58
  ADDON_CONTRIBUTOR_UNKNOWN = "PKU363",
59
59
  ADDON_LEGACY_CONTRIBUTORS = "PKU364",
60
60
  STUDIO_SCREEN_AGENT_INVALID = "PKU365",
61
+ STUDIO_PACKAGE_FILES_MISSING = "PKU366",
61
62
  DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
62
63
  DB_JSON_COLUMN_UNTYPED = "PKU481",
63
64
  DB_FORMAT_ON_NON_STRING = "PKU482",
@@ -61,6 +61,7 @@ export var ErrorCode;
61
61
  ErrorCode["ADDON_CONTRIBUTOR_UNKNOWN"] = "PKU363";
62
62
  ErrorCode["ADDON_LEGACY_CONTRIBUTORS"] = "PKU364";
63
63
  ErrorCode["STUDIO_SCREEN_AGENT_INVALID"] = "PKU365";
64
+ ErrorCode["STUDIO_PACKAGE_FILES_MISSING"] = "PKU366";
64
65
  // Database schema codegen warnings
65
66
  ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
66
67
  ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
package/dist/types.d.ts CHANGED
@@ -553,6 +553,7 @@ export type StudioScreenMeta = {
553
553
  agentTitle?: string;
554
554
  /** Message key from the agent file's frontmatter, filled by validation. */
555
555
  agentDescription?: string;
556
+ defaultChat?: boolean;
556
557
  } & ({
557
558
  component: string;
558
559
  app?: undefined;
@@ -772,6 +773,16 @@ export interface InspectorState {
772
773
  auth?: boolean;
773
774
  tags?: string[];
774
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
+ };
775
786
  /** True when declared via `wireRemoteAddon` — import the addon's `.remote.gen` map, not `.internal.gen` */
776
787
  remote?: boolean;
777
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.113",
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.150"
73
+ "@pikku/core": "^0.12.151"
74
74
  },
75
75
  "devDependencies": {
76
- "@pikku/core": "^0.12.150",
76
+ "@pikku/core": "^0.12.151",
77
77
  "@types/node": "^26.6.5"
78
78
  },
79
79
  "engines": {