@subbly/plugin-lint 0.0.5 → 0.0.8

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @subbly/plugin-lint
2
2
 
3
+ ## 0.0.8
4
+
5
+ ### Patch Changes
6
+
7
+ - Plugin automations are now workflows, as in the builder. The manifest key is `extensions["co.subbly.builder"].workflows` and the instruction files live in `co.subbly.builder/workflows/<slug>.md`. The rule ids `automation/*` are now `workflow/*`. A manifest that still declares `automations` fails rule `manifest/namespace-unknown-key`, as the builder refresh rejects it.
8
+
9
+ ## 0.0.7
10
+
11
+ ### Patch Changes
12
+
13
+ - A config field may carry `description` beside `label`: a string of 1-300 characters after trimming, shown under the input in the install dialog. Before, the linter rejected it as an unknown key (rule `manifest/namespace-unknown-key`), though the builder accepts it. A blank or over-long description fails rule `field/description`.
14
+
15
+ ## 0.0.6
16
+
17
+ ### Patch Changes
18
+
19
+ - 937b7e6: A connector marker may carry `liveTools` beside `auth` and `tools`: the read tools that a live page may call. It is a non-empty array of unique tool names, 1-100 characters each, as `tools` is. When `tools` is there too, each live tool must also be in `tools` (rule `connector/live-tools`). A marker with none of `auth`, `tools` and `liveTools` is rejected, as the builder refresh rejects it.
20
+
3
21
  ## 0.0.5
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -58,7 +58,7 @@ export default [
58
58
  ]
59
59
  ```
60
60
 
61
- The gate is cross-file: whether a `plugin.json` is valid depends on `marketplace.json`, and a missing `automations/<slug>.md` is a defect in a file that does not exist. ESLint visits one existing file at a time, so the engine runs once per invocation and hands each file the findings that belong to it. Every rule reaches the editor, absence checks included.
61
+ The gate is cross-file: whether a `plugin.json` is valid depends on `marketplace.json`, and a missing `workflows/<slug>.md` is a defect in a file that does not exist. ESLint visits one existing file at a time, so the engine runs once per invocation and hands each file the findings that belong to it. Every rule reaches the editor, absence checks included.
62
62
 
63
63
  ## Library
64
64
 
@@ -74,7 +74,7 @@ console.log(report.errors.length)
74
74
 
75
75
  ## What it checks
76
76
 
77
- `marketplace.json` and every plugin manifest, `mcp.json` servers, skill and agent frontmatter, automation declarations against their files, cron floors, script lockfiles, plugin layout, and sandbox paths written in prose. Run `subbly-plugin-lint --rules` for the full list.
77
+ `marketplace.json` and every plugin manifest, `mcp.json` servers, skill and agent frontmatter, workflow declarations against their files, cron floors, script lockfiles, plugin layout, and sandbox paths written in prose. Run `subbly-plugin-lint --rules` for the full list.
78
78
 
79
79
  ## Two severities
80
80
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@subbly/plugin-lint",
3
- "version": "0.0.5",
3
+ "version": "0.0.8",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "The Subbly marketplace release gate, offline. Lints marketplace.json, plugin manifests, mcp.json, skills, agents and automations.",
6
+ "description": "The Subbly marketplace release gate, offline. Lints marketplace.json, plugin manifests, mcp.json, skills, agents and workflows.",
7
7
  "license": "MIT",
8
8
  "author": "Subbly LTD <oleg@subbly.co> (https://www.subbly.co)",
9
9
  "publishConfig": {
@@ -31,10 +31,10 @@
31
31
  "README.md",
32
32
  "CHANGELOG.md"
33
33
  ],
34
- "engines": {
35
- "node": ">=20"
36
- },
37
34
  "scripts": {
38
35
  "test": "node test/lint.test.mjs"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
39
  }
40
- }
40
+ }
package/src/cron.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // Cron parsing and the builder 15-minute schedule floor.
2
2
  //
3
- // Mirrors packages/core/src/lib/automations/schedule.ts: 101 firings sampled
3
+ // Mirrors packages/core/src/lib/workflows/schedule.ts: 101 firings sampled
4
4
  // from 2026-01-01T00:00:00Z, rejected if any gap falls under the floor.
5
5
 
6
6
  export const SCHEDULE_FLOOR_MINUTES = 15
@@ -1,7 +1,7 @@
1
1
  // ESLint bridge.
2
2
  //
3
3
  // The release gate is cross-file: whether plugin.json is valid depends on
4
- // marketplace.json, and a missing automations/<slug>.md is a defect in a file
4
+ // marketplace.json, and a missing workflows/<slug>.md is a defect in a file
5
5
  // that does not exist. ESLint visits one existing file at a time, so rules
6
6
  // cannot be written that way directly.
7
7
  //
package/src/report.mjs CHANGED
@@ -41,6 +41,7 @@ export const RULES = {
41
41
  'field/key-format': 'field keys must match /^[A-Z][A-Z0-9_]*$/',
42
42
  'field/key-duplicate': 'a duplicate field key silently overrides the first',
43
43
  'field/label': 'label is required, 1-100 characters',
44
+ 'field/description': 'description is optional, 1-300 characters after trimming',
44
45
  'field/type': 'type must be "text" or "select"',
45
46
  'field/options': 'options belong to select fields and select fields require them',
46
47
  'field/public-secret': 'public and secret are mutually exclusive',
@@ -49,24 +50,25 @@ export const RULES = {
49
50
  'field/default-required': 'a default plugin cannot declare required fields',
50
51
  'field/reserved-key': 'a field key must not restate the PLUGIN_ prefix the builder adds',
51
52
 
52
- // automations
53
- 'automation/key-slug': 'automation keys must be valid slugs',
54
- 'automation/unknown-key': 'a declaration holds exactly name, schedule and model',
55
- 'automation/name': 'name is required, 1-100 characters',
56
- 'automation/schedule-syntax': 'schedule must be a cron expression the builder can parse',
57
- 'automation/schedule-floor': 'schedule must fire at most every 15 minutes',
58
- 'automation/schedule-unverified': 'this cron syntax cannot be checked offline',
59
- 'automation/model': 'model must be normal or intelligent-high',
60
- 'automation/missing-file': 'every declared automation needs its markdown file',
61
- 'automation/orphan-file': 'every automation file needs a declaration',
62
- 'automation/empty-file': 'an automation file must not be empty',
63
- 'automation/frontmatter': 'automation frontmatter is not stripped and leaks into the instruction',
53
+ // workflows
54
+ 'workflow/key-slug': 'workflow keys must be valid slugs',
55
+ 'workflow/unknown-key': 'a declaration holds exactly name, schedule and model',
56
+ 'workflow/name': 'name is required, 1-100 characters',
57
+ 'workflow/schedule-syntax': 'schedule must be a cron expression the builder can parse',
58
+ 'workflow/schedule-floor': 'schedule must fire at most every 15 minutes',
59
+ 'workflow/schedule-unverified': 'this cron syntax cannot be checked offline',
60
+ 'workflow/model': 'model must be normal or intelligent-high',
61
+ 'workflow/missing-file': 'every declared workflow needs its markdown file',
62
+ 'workflow/orphan-file': 'every workflow file needs a declaration',
63
+ 'workflow/empty-file': 'a workflow file must not be empty',
64
+ 'workflow/frontmatter': 'workflow frontmatter is not stripped and leaks into the instruction',
64
65
  'setup/missing-install-skill': 'setup: true requires a skills/install skill',
65
66
 
66
67
  // connectors and mcp.json
67
- 'connector/marker': 'a connector marker holds auth, tools or both, and nothing else',
68
+ 'connector/marker': 'a connector marker holds one or more of auth, tools and liveTools, and nothing else',
68
69
  'connector/auth': 'auth, when present, is exactly "oauth"',
69
- 'connector/tools': 'tools is a non-empty array of unique tool names, 1-100 characters each',
70
+ 'connector/tools': 'tools and liveTools are each a non-empty array of unique tool names, 1-100 characters each',
71
+ 'connector/live-tools': 'each live tool is also in tools, when tools is present',
70
72
  'connector/name-length': 'connector names are 1-100 characters',
71
73
  'connector/undefined-server': 'a declared connector needs a matching mcp.json server',
72
74
  'connector/no-mcp': 'declaring a connector requires an mcp.json',
package/src/rules.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Mirrors subbly-builder branch feat/plugins-mcp:
4
4
  // packages/plugins/src/marketplace/{marketplace-manifest,plugin-manifest,mcp-config}.ts
5
5
  // packages/core/src/types/plugin-manifest.ts
6
- // packages/core/src/lib/automations/schedule.ts
6
+ // packages/core/src/lib/workflows/schedule.ts
7
7
  //
8
8
  // error = the refresh throws, so NO plugin in the marketplace releases.
9
9
  // warn = the builder accepts it, but it is wrong, dead, or broken at runtime.
@@ -34,10 +34,10 @@ const CONNECTOR_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,49}$/
34
34
  const CONTENT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i
35
35
 
36
36
  const MANIFEST_KEYS = ['$schema', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', 'extensions']
37
- const NAMESPACE_KEYS = ['displayName', 'image', 'default', 'fields', 'automations', 'setup', 'connectors']
38
- const FIELD_KEYS = ['key', 'label', 'type', 'required', 'secret', 'public', 'pattern', 'options']
39
- const AUTOMATION_KEYS = ['name', 'schedule', 'model']
40
- const AUTOMATION_MODELS = ['normal', 'intelligent-high']
37
+ const NAMESPACE_KEYS = ['displayName', 'image', 'default', 'fields', 'workflows', 'setup', 'connectors']
38
+ const FIELD_KEYS = ['key', 'label', 'description', 'type', 'required', 'secret', 'public', 'pattern', 'options']
39
+ const WORKFLOW_KEYS = ['name', 'schedule', 'model']
40
+ const WORKFLOW_MODELS = ['normal', 'intelligent-high']
41
41
  const SERVER_KEYS = ['type', 'url', 'headers']
42
42
 
43
43
  export const AGENT_TOOLS = ['read_file', 'write_file', 'edit_file', 'apply_patch', 'execute_command', 'get_stock_image', 'web_fetch', 'web_search', 'generate_image', 'restart_preview', 'apply_to_preview', 'tool_run', 'tool_search', 'tool_describe', 'skill']
@@ -53,7 +53,7 @@ export const DEFAULT_ENV_KEYS = [
53
53
 
54
54
  const RESERVED_SLUG = 'custom'
55
55
 
56
- const MISPLACED = ['agents', 'automations', 'scripts', 'instructions.md']
56
+ const MISPLACED = ['agents', 'workflows', 'scripts', 'instructions.md']
57
57
 
58
58
  // The builder-maintained symlink to the installed version, the only stable
59
59
  // segment prose may use in a sandbox path.
@@ -409,7 +409,7 @@ function lintPluginManifest(root, entry, report) {
409
409
  check.boolean('manifest/boolean', `${ns}.setup`, ext.setup)
410
410
 
411
411
  const fields = lintFields(report, file, at, check, ns, ext, entry.slug)
412
- const automations = lintAutomationDeclarations(report, file, at, check, ns, ext)
412
+ const workflows = lintWorkflowDeclarations(report, file, at, check, ns, ext)
413
413
  const connectors = lintConnectors(report, file, at, check, ns, ext)
414
414
 
415
415
  if (ext.default === true && fields.some((f) => f.required === true)) {
@@ -421,7 +421,7 @@ function lintPluginManifest(root, entry, report) {
421
421
  )
422
422
  }
423
423
 
424
- return { fieldKeys: fields.map((f) => f.key).filter(Boolean), automations, connectors, setup: ext.setup === true }
424
+ return { fieldKeys: fields.map((f) => f.key).filter(Boolean), workflows, connectors, setup: ext.setup === true }
425
425
  }
426
426
 
427
427
  function lintFields(report, file, at, check, ns, ext, slug) {
@@ -458,6 +458,9 @@ function lintFields(report, file, at, check, ns, ext, slug) {
458
458
  }
459
459
 
460
460
  check.string('field/label', `${p}.label`, field.label, { min: 1, max: 100, required: true, label: `${label}.label` })
461
+ if (field.description !== undefined) {
462
+ check.string('field/description', `${p}.description`, field.description, { min: 1, max: 300, trim: true, label: `${label}.description` })
463
+ }
461
464
 
462
465
  if (field.type !== 'text' && field.type !== 'select') {
463
466
  report.error('field/type', file, `${label}.type: expected "text" or "select", received ${JSON.stringify(field.type)}`, at(`${p}.type`))
@@ -507,39 +510,39 @@ function lintFields(report, file, at, check, ns, ext, slug) {
507
510
  return ext.fields.filter(isObject)
508
511
  }
509
512
 
510
- function lintAutomationDeclarations(report, file, at, check, ns, ext) {
511
- if (ext.automations === undefined) return []
512
- if (!isObject(ext.automations)) {
513
- report.error('automation/key-slug', file, `${ns}.automations: expected object keyed by slug, received ${typeOf(ext.automations)}`, at(`${ns}.automations`))
513
+ function lintWorkflowDeclarations(report, file, at, check, ns, ext) {
514
+ if (ext.workflows === undefined) return []
515
+ if (!isObject(ext.workflows)) {
516
+ report.error('workflow/key-slug', file, `${ns}.workflows: expected object keyed by slug, received ${typeOf(ext.workflows)}`, at(`${ns}.workflows`))
514
517
  return []
515
518
  }
516
519
 
517
520
  const slugs = []
518
- for (const [slug, decl] of Object.entries(ext.automations)) {
519
- const p = `${ns}.automations.${slug}`
520
- const label = `automations["${slug}"]`
521
+ for (const [slug, decl] of Object.entries(ext.workflows)) {
522
+ const p = `${ns}.workflows.${slug}`
523
+ const label = `workflows["${slug}"]`
521
524
 
522
525
  if (slug.length < 1 || slug.length > 64 || !SLUG_PATTERN.test(slug)) {
523
- report.error('automation/key-slug', file, `${label}: key ${SLUG_MESSAGE}`, at(p, { key: true }))
526
+ report.error('workflow/key-slug', file, `${label}: key ${SLUG_MESSAGE}`, at(p, { key: true }))
524
527
  continue
525
528
  }
526
529
  if (!isObject(decl)) {
527
- report.error('automation/unknown-key', file, `${label}: expected object, received ${typeOf(decl)}`, at(p))
530
+ report.error('workflow/unknown-key', file, `${label}: expected object, received ${typeOf(decl)}`, at(p))
528
531
  continue
529
532
  }
530
533
 
531
- check.strictKeys('automation/unknown-key', p, decl, AUTOMATION_KEYS, label)
532
- check.string('automation/name', `${p}.name`, decl.name, { min: 1, max: 100, trim: true, required: true, label: `${label}.name` })
534
+ check.strictKeys('workflow/unknown-key', p, decl, WORKFLOW_KEYS, label)
535
+ check.string('workflow/name', `${p}.name`, decl.name, { min: 1, max: 100, trim: true, required: true, label: `${label}.name` })
533
536
 
534
- if (check.string('automation/schedule-syntax', `${p}.schedule`, decl.schedule, { min: 1, max: 100, required: true, label: `${label}.schedule` })) {
537
+ if (check.string('workflow/schedule-syntax', `${p}.schedule`, decl.schedule, { min: 1, max: 100, required: true, label: `${label}.schedule` })) {
535
538
  lintSchedule(report, file, at(`${p}.schedule`), label, decl.schedule)
536
539
  }
537
540
 
538
- if (!AUTOMATION_MODELS.includes(decl.model)) {
541
+ if (!WORKFLOW_MODELS.includes(decl.model)) {
539
542
  report.error(
540
- 'automation/model',
543
+ 'workflow/model',
541
544
  file,
542
- `${label}.model: expected ${AUTOMATION_MODELS.join(' | ')}, received ${JSON.stringify(decl.model)}`,
545
+ `${label}.model: expected ${WORKFLOW_MODELS.join(' | ')}, received ${JSON.stringify(decl.model)}`,
543
546
  at(`${p}.model`)
544
547
  )
545
548
  }
@@ -554,17 +557,17 @@ function lintSchedule(report, file, pos, label, schedule) {
554
557
  cron = parseCron(schedule)
555
558
  } catch (e) {
556
559
  if (e instanceof CronUnsupported) {
557
- report.warn('automation/schedule-unverified', file, `${label}.schedule contains "${e.message}", which cannot be checked offline`, {
560
+ report.warn('workflow/schedule-unverified', file, `${label}.schedule contains "${e.message}", which cannot be checked offline`, {
558
561
  ...pos,
559
562
  hint: 'the builder may accept it; confirm the 15-minute floor by hand',
560
563
  })
561
564
  return
562
565
  }
563
- report.error('automation/schedule-syntax', file, `${label}.schedule: invalid cron expression (${e.message})`, pos)
566
+ report.error('workflow/schedule-syntax', file, `${label}.schedule: invalid cron expression (${e.message})`, pos)
564
567
  return
565
568
  }
566
569
  if (!meetsFloor(cron)) {
567
- report.error('automation/schedule-floor', file, `${label}.schedule must fire at most every ${SCHEDULE_FLOOR_MINUTES} minutes`, pos)
570
+ report.error('workflow/schedule-floor', file, `${label}.schedule must fire at most every ${SCHEDULE_FLOOR_MINUTES} minutes`, pos)
568
571
  }
569
572
  }
570
573
 
@@ -587,9 +590,9 @@ function lintConnectors(report, file, at, check, ns, ext) {
587
590
  report.error('connector/marker', file, `${label}: expected object, received ${typeOf(marker)}`, at(p))
588
591
  continue
589
592
  }
590
- check.strictKeys('connector/marker', p, marker, ['auth', 'tools'], label)
591
- if (marker.auth === undefined && marker.tools === undefined) {
592
- report.error('connector/marker', file, `${label}: declare auth, tools or both`, at(p))
593
+ check.strictKeys('connector/marker', p, marker, ['auth', 'tools', 'liveTools'], label)
594
+ if (marker.auth === undefined && marker.tools === undefined && marker.liveTools === undefined) {
595
+ report.error('connector/marker', file, `${label}: declare auth, tools or liveTools`, at(p))
593
596
  }
594
597
  if (marker.auth !== undefined && marker.auth !== 'oauth') {
595
598
  report.error('connector/auth', file, `${label}.auth: expected "oauth", received ${JSON.stringify(marker.auth)}`, at(`${p}.auth`))
@@ -597,6 +600,10 @@ function lintConnectors(report, file, at, check, ns, ext) {
597
600
  if (marker.tools !== undefined) {
598
601
  lintToolAllowlist(report, file, at, check, `${p}.tools`, `${label}.tools`, marker.tools)
599
602
  }
603
+ if (marker.liveTools !== undefined) {
604
+ lintToolAllowlist(report, file, at, check, `${p}.liveTools`, `${label}.liveTools`, marker.liveTools)
605
+ lintLiveToolsInTools(report, file, at, p, label, marker)
606
+ }
600
607
  names.push(name)
601
608
  }
602
609
  return names
@@ -624,6 +631,15 @@ function lintToolAllowlist(report, file, at, check, p, label, tools) {
624
631
  }
625
632
  }
626
633
 
634
+ function lintLiveToolsInTools(report, file, at, p, label, marker) {
635
+ if (!Array.isArray(marker.tools) || !Array.isArray(marker.liveTools)) return
636
+ for (const [index, tool] of marker.liveTools.entries()) {
637
+ if (typeof tool === 'string' && !marker.tools.includes(tool)) {
638
+ report.error('connector/live-tools', file, `${label}.liveTools[${index}]: live tool "${tool}" must also be in tools`, at(`${p}.liveTools.${index}`))
639
+ }
640
+ }
641
+ }
642
+
627
643
  // --- mcp.json --------------------------------------------------------------
628
644
 
629
645
  function lintMcp(root, entry, manifest, report) {
@@ -960,10 +976,10 @@ function lintAgents(root, entry, report) {
960
976
  }
961
977
  }
962
978
 
963
- // --- automations -----------------------------------------------------------
979
+ // --- workflows -----------------------------------------------------------
964
980
 
965
- function lintAutomationFiles(root, entry, declared, report) {
966
- const dir = join(entry.dir, NAMESPACE, 'automations')
981
+ function lintWorkflowFiles(root, entry, declared, report) {
982
+ const dir = join(entry.dir, NAMESPACE, 'workflows')
967
983
  const present = new Set()
968
984
 
969
985
  for (const dirent of listDir(dir)) {
@@ -975,10 +991,10 @@ function lintAutomationFiles(root, entry, declared, report) {
975
991
  const orphans = [...present].filter((s) => !declared.includes(s))
976
992
  for (const orphan of orphans) {
977
993
  report.error(
978
- 'automation/orphan-file',
994
+ 'workflow/orphan-file',
979
995
  relative(root, join(dir, `${orphan}.md`)),
980
- `Invalid automations of ${entry.slug}: files without a manifest declaration: ${orphans.join(', ')}`,
981
- { line: 1, hint: `declare it in plugin.json under extensions["${NAMESPACE}"].automations, or delete it` }
996
+ `Invalid workflows of ${entry.slug}: files without a manifest declaration: ${orphans.join(', ')}`,
997
+ { line: 1, hint: `declare it in plugin.json under extensions["${NAMESPACE}"].workflows, or delete it` }
982
998
  )
983
999
  }
984
1000
 
@@ -986,19 +1002,19 @@ function lintAutomationFiles(root, entry, declared, report) {
986
1002
  const path = join(dir, `${slug}.md`)
987
1003
  if (!present.has(slug)) {
988
1004
  report.error(
989
- 'automation/missing-file',
1005
+ 'workflow/missing-file',
990
1006
  relative(root, join(entry.dir, 'plugin.json')),
991
- `Invalid automations of ${entry.slug}: missing automations/${slug}.md`,
992
- { hint: `create ${NAMESPACE}/automations/${slug}.md` }
1007
+ `Invalid workflows of ${entry.slug}: missing workflows/${slug}.md`,
1008
+ { hint: `create ${NAMESPACE}/workflows/${slug}.md` }
993
1009
  )
994
1010
  continue
995
1011
  }
996
1012
  const text = readIfExists(path) ?? ''
997
1013
  if (text.trim() === '') {
998
- report.error('automation/empty-file', relative(root, path), `Invalid automations of ${entry.slug}: automations/${slug}.md is empty`, { line: 1 })
1014
+ report.error('workflow/empty-file', relative(root, path), `Invalid workflows of ${entry.slug}: workflows/${slug}.md is empty`, { line: 1 })
999
1015
  }
1000
1016
  if (text.trimStart().startsWith('---')) {
1001
- report.warn('automation/frontmatter', relative(root, path), 'frontmatter here is not parsed or stripped, so it lands verbatim in the agent instruction', {
1017
+ report.warn('workflow/frontmatter', relative(root, path), 'frontmatter here is not parsed or stripped, so it lands verbatim in the agent instruction', {
1002
1018
  line: 1,
1003
1019
  hint: 'name, schedule and model belong in plugin.json',
1004
1020
  })
@@ -1163,7 +1179,7 @@ export function lint(root, { git = true } = {}) {
1163
1179
  lintMcp(root, entry, manifest, report)
1164
1180
  lintSkills(root, entry, report)
1165
1181
  lintAgents(root, entry, report)
1166
- lintAutomationFiles(root, entry, manifest?.automations ?? [], report)
1182
+ lintWorkflowFiles(root, entry, manifest?.workflows ?? [], report)
1167
1183
  lintSetup(root, entry, manifest, report)
1168
1184
  lintLayout(root, entry, report)
1169
1185
  }