Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/os-g-skill-scaffolder.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/cli": minor
---

feat(cli): `os g skill NAME` scaffolds an AI skill, and writes it as `NAME.skill.ts` so the loader can find it (#11025)

Completes the second half of the ADR-0063 Option A ruling whose first half
retired `os g agent` (#10359). That retirement left authors told to write
`src/skills/NAME.skill.ts` by hand because no scaffolder existed; this adds it,
and `os g agent`'s refusal, the CLI README and the CLI docs now name the
command instead of apologising for its absence.

The filename is the point, not a detail. `DEFAULT_METADATA_TYPE_REGISTRY`
declares `skill`'s file convention as `*.skill.ts` / `*.skill.yml`, while this
harness has always written `NAME.ts`. `skill` is `allowRuntimeCreate: true` —
a type the platform expects to discover — so a scaffold matching no pattern
would type-check, validate and publish with nothing anywhere reporting that it
had been skipped: the silent-strip shape the `agent` retirement closed,
re-entering through the scaffolder that replaced it. `skill` therefore
overrides the harness filename through a new per-generator hook, and the
barrel re-export is derived from the file that was actually written rather
than rebuilt from the metadata name.

**The other six generators are unchanged** and still write `NAME.ts` with a
`'./NAME'` barrel line, pinned by a control assertion in the new test.
Converging the whole scaffolder on the registry's `NAME.TYPE.ts` convention —
the shape the example apps already author in — moves every generator's output
plus the docs and examples that show it, and is deliberately left as its own
decision.

Three authoring choices the template makes, each written into the generated
file so the next author inherits the reasoning and not just the value:
`tools: []`, because under ADR-0064 an agent's tool set is the union of its
skills' tools with no global fall-through, so an empty list grants nothing
while a placeholder name would resolve to nothing and be reported by
`os validate` as `ai-skill-tool-unresolved`; `surface: 'ask'` written out
rather than left to the schema default, because the affinity it declares is
enforced at load and a default taken in silence is invisible to whoever edits
the file next; and `defineSkill` rather than a bare typed literal, so the
object is parsed at module load. The template is **not** copied from
`SkillSchema`'s or `defineSkill`'s `@example` blocks — both pass
`triggerPhrases`, a retired-key tombstone that rejects on parse (#11026).
24 changes: 14 additions & 10 deletions .changeset/retire-agent-generator.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,26 +41,30 @@ What you see:
Author a SKILL instead. Skills (plus tools / MCP) are the third-party
extension primitive ADR-0063 names — the live surface this one was not.

There is no `os g skill` scaffolder yet. Write the file by hand:
Scaffold one — the file lands where the loader looks for it:

src/skills/<name>.skill.ts
import { defineSkill } from '@objectstack/spec/ai';
os g skill <name> -> src/skills/<name>.skill.ts

It writes a `defineSkill` template with `surface` and `tools` filled in
and explained, ready to edit.

Docs: https://objectstack.ai/docs/ai/agents
```

**There is no command to rewrite the call into.** A skill is a different
artifact with a different schema, not a renamed agent, and `os g skill` does not
exist yet — the skill scaffolder is tracked separately, and this message says so
rather than promising it. Delete the call; author the skill file by hand.
**The call is not mechanically rewritable.** A skill is a different artifact
with a different schema, not a renamed agent, so delete the `os g agent` call
rather than renaming it — then run `os g skill` and fill the template in. (This
message originally said no scaffolder existed; `os g skill` shipped in the same
release, so the text above is what the command prints today.)

The generator roster is now `object`, `view`, `action`, `flow`, `dashboard`,
`app`. The docs that advertised the retired one — the `os g agent support`
`agent` leaves the generator roster, which is `object`, `view`, `action`,
`flow`, `dashboard`, `app` — plus `skill`, added in this same release. The docs
that advertised the retired one — the `os g agent support`
example, the `agent` / `src/agents/` row of the Available types table, and
`os g agent sales-assistant` in the Typical Workflow block — are gone from
`content/docs/deployment/cli.mdx`, which carries the retirement note instead;
`packages/cli/README.md`'s type roster follows. The quick-start project-layout
map, which listed `src/agents` as the directory an app author writes AI metadata
into, now names `src/skills`.

<!-- adr-0087: not-required (no-migration-prescription) A CLI COMMAND NAME is an invocation surface, not authorable metadata. There is no authorable key, no `sys_metadata` row and no schema to tombstone here, so there is nothing for `objectstack migrate meta` to rewrite, nothing for `spec-changes.json` to project and no FROM -> TO spelling for the upgrade guide to carry: a skill is a different artifact rather than a renamed agent, and no replacement command exists to rewrite the call into. Nor is the ledger the only notification channel this time, which is the difference from `http-request-errors-total-retired` (where an operator's Grafana panel silently drew a flat zero and the entry was the sole way to say so): the command itself now refuses, exits 1, names ADR-0063 and points at skills at the exact moment and place of use. Same reasoning shape as ADR-0087's D7 addendum, one surface over — there the compiler carries the notice, here the CLI does. -->
<!-- adr-0087: not-required (no-migration-prescription) A CLI COMMAND NAME is an invocation surface, not authorable metadata. There is no authorable key, no `sys_metadata` row and no schema to tombstone here, so there is nothing for `objectstack migrate meta` to rewrite, nothing for `spec-changes.json` to project and no FROM -> TO spelling for the upgrade guide to carry: a skill is a different artifact rather than a renamed agent, so even with `os g skill` shipping in this same release there is no FROM -> TO call rewrite to prescribe — the author deletes the call and fills in a scaffolded template. Nor is the ledger the only notification channel this time, which is the difference from `http-request-errors-total-retired` (where an operator's Grafana panel silently drew a flat zero and the entry was the sole way to say so): the command itself now refuses, exits 1, names ADR-0063 and points at skills at the exact moment and place of use. Same reasoning shape as ADR-0087's D7 addendum, one surface over — there the compiler carries the notice, here the CLI does. -->
34 changes: 24 additions & 10 deletions content/docs/deployment/cli.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1080,21 +1080,35 @@ os g action approve # Generate an action
os g flow customer # Generate an automation flow
os g dashboard sales # Generate a dashboard
os g app crm # Generate an app definition
os g skill lead-qual # Generate an AI skill

os g object task -d lib/ # Override target directory
os g object task --dry-run # Preview without writing
```

**Available types:**

| Type | Default Directory | Description |
|------|------------------|-------------|
| `object` | `src/objects/` | Business data object with fields |
| `view` | `src/views/` | List or form view definition |
| `action` | `src/actions/` | Button or batch action |
| `flow` | `src/flows/` | Automation flow |
| `dashboard` | `src/dashboards/` | Analytics dashboard |
| `app` | `src/apps/` | Application navigation |
| Type | Default Directory | Written as | Description |
|------|------------------|------------|-------------|
| `object` | `src/objects/` | `NAME.ts` | Business data object with fields |
| `view` | `src/views/` | `NAME.ts` | List or form view definition |
| `action` | `src/actions/` | `NAME.ts` | Button or batch action |
| `flow` | `src/flows/` | `NAME.ts` | Automation flow |
| `dashboard` | `src/dashboards/` | `NAME.ts` | Analytics dashboard |
| `app` | `src/apps/` | `NAME.ts` | Application navigation |
| `skill` | `src/skills/` | `NAME.skill.ts` | AI skill — the ADR-0063 extension primitive |

<Callout type="info" title="Why `skill` alone gets a filename suffix">
`skill` is the one type whose scaffold is written as `NAME.skill.ts` rather
than `NAME.ts`. The metadata type registry declares that type's file
convention as `*.skill.ts` / `*.skill.yml`, and `skill` is discoverable
metadata — a file matching neither pattern still type-checks, still validates
and still publishes, with nothing anywhere reporting that it was skipped.

The other six generators keep `NAME.ts`. Aligning the whole scaffolder with
the registry's `NAME.TYPE.ts` convention — the shape the example apps already
author in — would change every generator's output and is a separate decision.
</Callout>

<Callout type="warn" title="`os g agent` is retired">
There is no `agent` type. Running `os g agent <name>` fails with a message
Expand All@@ -1106,8 +1120,8 @@ Agents are **platform-internal**: the kernel ships exactly two (`ask` and
scaffolded `src/agents/*.ts` therefore passed `os validate`, published without
complaint, and never appeared — silently. Skills (plus tools / MCP) are the
third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
`defineSkill`; see [AI Agents](/docs/ai/agents). There is no `os g skill`
scaffolder yet, so write that file by hand for now.
`defineSkill`; see [AI Agents](/docs/ai/agents). Scaffold one with
`os g skill <name>`, which writes exactly that path.
</Callout>

**Options:**
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,13 +59,20 @@ os compile
| `os generate <type> <name>` | Generate metadata files (alias: `os g`) |
| `os create <type> [name]` | Create a new package/plugin/example from template |

Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app`
Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`

`agent` is **retired** (ADR-0063 §2): agents are platform-internal, so a scaffolded
`src/agents/*.ts` validated, published and was then filtered out of the runtime
catalog without a word. `os g agent` now says so and points at skills — the
third-party extension primitive — which are authored as `src/skills/<name>.skill.ts`
with `defineSkill`. There is no `os g skill` scaffolder yet.
with `defineSkill`, and which `os g skill <name>` scaffolds for you.

`skill` is the one type written as `NAME.skill.ts` rather than the harness's usual
`NAME.ts`: the metadata type registry declares that type's file convention as
`*.skill.ts` / `*.skill.yml`, and a discoverable-metadata file matching no pattern
type-checks, validates and publishes with nothing reporting that it was skipped.
Aligning the other six generators with the registry's `NAME.TYPE.ts` convention is
a separate decision and is deliberately not made here.

### Cloud — publish & install

Expand Down
125 changes: 118 additions & 7 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,21 @@ const GENERATORS: Record<string, {
description: string;
defaultDir: string;
generate: (name: string) => string;
/**
* Per-generator override for the written file's name. Optional, and today
* exactly one generator sets it — see `skill` below for why that one is
* different and why the divergence was not resolved the other way.
*
* The default is `NAME.ts`, which is what this harness has always written.
* `DEFAULT_METADATA_TYPE_REGISTRY` (`packages/spec`) meanwhile gives every
* metadata type `filePatterns` of the form `NAME.TYPE.ts`, and the example
* apps author that way (`account.object.ts`, `lead.view.ts`). Converging
* the harness on the registry's convention for all seven types is the
* repo-wide change that would settle the mismatch properly — it moves every
* generator's output plus the docs and examples that show it, so it is its
* own decision and deliberately does NOT ride in here (#11025).
*/
fileName?: (name: string) => string;
}> = {
object: {
description: 'Business data object',
Expand DownExpand Up@@ -161,6 +176,91 @@ const ${toCamelCase(name)}App: UI.App = {
};

export default ${toCamelCase(name)}App;
`,
},

skill: {
description: 'AI skill (ADR-0063 extension primitive)',
defaultDir: 'src/skills',
/**
* The ONE generator that overrides the harness's `NAME.ts` convention, and
* the reason is not cosmetic.
*
* `DEFAULT_METADATA_TYPE_REGISTRY` declares this type's file convention
* as `*.skill.ts` / `*.skill.yml`, and that declaration is load-bearing:
* `MetadataPlugin._loadFromFileSystem` globs each registered type by its
* own `filePatterns`, `MetadataManager.getTypeInfo` publishes them to
* Studio and every other type-descriptor consumer, and it is the shape
* the example apps and the manifest's own glob keys are written in
* throughout (`account.object.ts`, `lead.view.ts`, and so on for every
* type).
*
* A scaffold written as `lead_qualification.ts` matches NEITHER pattern.
* `skill` is `allowRuntimeCreate: true` — a type the platform expects to
* discover rather than one wired in by hand — and a file no pattern
* matches still type-checks, still passes `os validate` and still
* publishes, with nothing anywhere saying it was skipped. That is the
* silent-strip shape ADR-0063's retirement of `os g agent` closed
* (#10359), re-entering through the scaffolder that replaced it
* (#11025), which is why a template alone would have been worse than no
* generator at all.
*
* Scoped to `skill` rather than fixed for all seven types on purpose: the
* other six write into the same mismatch, but nothing there is
* filesystem-discovered today, and moving the whole harness to
* `NAME.TYPE.ts` moves every existing generator's output plus the docs
* and examples that show it. That is a repo-wide decision of its own —
* see the `fileName` docblock above.
*/
fileName: (name: string) => `${toSnakeCase(name)}.skill.ts`,
generate: (name: string) => `import { defineSkill } from '@objectstack/spec/ai';

/**
* ${toTitleCase(name)} Skill
*
* Skills are the third-party AI extension primitive (ADR-0063 §2) — agents are
* platform-internal, so a skill, plus the declarative actions your app already
* ships, is how you give the assistant a new capability.
*
* Authored through \`defineSkill\` rather than as a bare typed literal so the
* object is parsed the moment this module loads: an unknown or retired key is
* a startup error naming the key, not a field that goes missing later.
*/
const ${toCamelCase(name)}Skill = defineSkill({
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
description: 'One line on what this skill is for — the model routes on it.',

// ADR-0063 §3 — the kernel agent surface this skill binds to, enforced at
// load time: 'ask' (the data console), 'build' (the authoring surface), or
// 'both' for a genuinely shared read-only capability. A skill only binds to
// an agent whose surface it matches. 'ask' is also the schema default, so
// writing it changes nothing at runtime; it is here because a default taken
// in silence is a decision the next author cannot see they are inheriting.
surface: 'ask',

// Injected into the active agent's system prompt, and projected onto the MCP
// \`prompts\` primitive by @objectstack/mcp — the half of a skill that runs in
// every distribution. This is the text the model actually reads.
instructions: 'Explain when this skill applies and how to use its tools.',

// Empty on purpose, and a complete skill as it stands: it contributes its
// instructions and no tools. Under ADR-0064 an agent's tool set is the union
// of its surface-compatible skills' tools with NO global fall-through, so an
// empty list grants nothing rather than everything.
//
// Fill it with names that resolve — a platform-registered tool, or
// \`action_NAME\` materialised from one of your own declarative actions that
// opts in with \`ai: { exposed: true, description: '…' }\` (ADR-0011/0109).
// A made-up placeholder would be worse than nothing: \`os validate\` reports
// it (\`ai-skill-tool-unresolved\`), and at runtime the reference is dropped
// while the instructions keep promising the capability.
//
// tools: ['action_${toSnakeCase(name)}', 'query_records'],
tools: [],
});

export default ${toCamelCase(name)}Skill;
`,
},
};
Expand DownExpand Up@@ -207,10 +307,12 @@ const RETIRED_GENERATORS: Record<string, {
'Author a SKILL instead. Skills (plus tools / MCP) are the third-party',
'extension primitive ADR-0063 names — the live surface this one was not.',
'',
'There is no `os g skill` scaffolder yet. Write the file by hand:',
'Scaffold one — the file lands where the loader looks for it:',
'',
' os g skill <name> -> src/skills/<name>.skill.ts',
'',
' src/skills/<name>.skill.ts',
" import { defineSkill } from '@objectstack/spec/ai';",
"It writes a `defineSkill` template with `surface` and `tools` filled in",
'and explained, ready to edit.',
'',
'Docs: https://objectstack.ai/docs/ai/agents',
],
Expand DownExpand Up@@ -364,7 +466,16 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
}

const dir = flags.dir || generator.defaultDir;
const fileName = `${toSnakeCase(name)}.ts`;
// `NAME.ts` unless the generator declares otherwise — see the `fileName`
// docblock on GENERATORS, and `skill`, the one type that must not use it.
const fileName = generator.fileName ? generator.fileName(name) : `${toSnakeCase(name)}.ts`;
// The barrel re-export has to name the file that was actually written, so
// it is derived from `fileName` rather than rebuilt from `name`. For the
// six generators that take the default this is byte-identical to the old
// `./${toSnakeCase(name)}`; for `skill` it keeps the `.skill` infix the
// loader's file pattern requires instead of pointing at a module that
// does not exist.
const moduleSpecifier = `./${fileName.replace(/\.ts$/, '')}`;
const filePath = path.join(process.cwd(), dir, fileName);

console.log(` ${chalk.dim('Type:')} ${chalk.cyan(type)} — ${generator.description}`);
Expand DownExpand Up@@ -407,15 +518,15 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
const indexPath = path.join(process.cwd(), dir, 'index.ts');
if (fs.existsSync(indexPath)) {
const indexContent = fs.readFileSync(indexPath, 'utf-8');
const exportLine = `export { default as ${toCamelCase(name)} } from './${toSnakeCase(name)}';`;
const exportLine = `export { default as ${toCamelCase(name)} } from '${moduleSpecifier}';`;

if (!indexContent.includes(toCamelCase(name))) {
fs.appendFileSync(indexPath, exportLine + '\n');
printSuccess(`Updated ${dir}/index.ts with export`);
}
} else {
// Create barrel index
const exportLine = `export { default as ${toCamelCase(name)} } from './${toSnakeCase(name)}';\n`;
const exportLine = `export { default as ${toCamelCase(name)} } from '${moduleSpecifier}';\n`;
fs.writeFileSync(indexPath, exportLine);
printSuccess(`Created ${dir}/index.ts`);
}
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/test/generate-agent-retired.e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,9 +25,10 @@
* `printError` writes to stdout. Spawned through `bin/run-dev.js` + tsx so the
* suite does not depend on `packages/cli/dist` having been built.
*
* ⛔ Not asserted, because it is out of scope and stays that way: that
* `os g skill` exists. It does not — the skill scaffolder is the split-out
* follow-up half, and the message says so rather than promising it.
* The refusal's pointer at skills is asserted here only as TEXT. That the
* command it now names actually exists and writes a loadable file is pinned
* next door, in `generate-skill.e2e.test.ts` (#11025) — when this message was
* first written there was no `os g skill` to point at, and it said so.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down
Loading
Loading