Skip to content

Commit 91f6252

Browse files
os-zhuangclaude
andcommitted
feat(cli): make the AI service opt-in via a declared dependency (+ honor config.tiers)
The CLI auto-registered the headless AIServicePlugin whenever the `ai` tier was enabled (default) and @objectstack/service-ai was merely *resolvable*. In a workspace/monorepo the package is hoist-resolvable even when an app doesn't declare it, so every app got the AI service — discovery reported services.ai available and the agent runtime served any metadata-defined agents — including Community-Edition apps that ship no AI. Make the DECLARED dependency the AI edition boundary: auto-register AIService only when the host app declares @objectstack/service-ai OR @objectstack/service-ai-studio (Studio attaches its personas via the base service's `ai:ready` hook, so declaring Studio implies the base — load it even when only Studio is in the deps). A CE app that declares neither gets no AI service, no agents, and services.ai { enabled:false, status:'unavailable' } in discovery — so the MIT console hides its AI surface — while MCP and every other capability are unaffected. Also honor `config.tiers`: ObjectStackDefinitionSchema gains a `tiers` field so defineStack stops stripping it. `config.tiers` (e.g. a list without `ai`) now actually overrides the `--preset` default — previously silently dropped by schema validation, which made the `--preset` help text inaccurate. A second, in-place lever to disable AI without touching dependencies. Declare @objectstack/service-ai in the showcase and crm examples (they ship AI agents) so they keep AI. Verified e2e in an isolated build: - app WITHOUT service-ai/studio → discovery AI unavailable, /api/v1/ai/agents 404, AIService not loaded; MCP server still starts + serves (401 auth-gate). - app declaring service-ai (or studio-only) → services.ai available + agents served. - config.tiers without `ai` → AIService not loaded even when service-ai IS declared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ffa0aca commit 91f6252

6 files changed

Lines changed: 72 additions & 1 deletion

File tree

.changeset/ce-ai-opt-in.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(cli): make the AI service opt-in via a declared dependency; honor `config.tiers`
7+
8+
**AI edition boundary (cli).** The CLI auto-registered the headless `AIServicePlugin`
9+
whenever the `ai` tier was enabled (default) and `@objectstack/service-ai` was
10+
merely *resolvable*. In a workspace/monorepo the package is hoist-resolvable even
11+
when an app does not declare it, so every app got the AI service — discovery
12+
reported `services.ai: available` and the agent runtime served any
13+
metadata-defined agents — including Community-Edition apps that ship no AI.
14+
15+
Now the *declared* dependency is the boundary: AIService auto-registers only when
16+
the host app declares `@objectstack/service-ai` **or** `@objectstack/service-ai-studio`
17+
(Studio attaches its personas via the base service's `ai:ready` hook, so declaring
18+
Studio implies the base). A CE app that declares neither gets no AI service, no
19+
agents, and `services.ai: { enabled: false, status: 'unavailable' }` in discovery
20+
(so the console hides its AI surface). MCP and every other capability are
21+
unaffected. The `app-showcase`/`app-crm` examples now declare `@objectstack/service-ai`.
22+
23+
**`config.tiers` now honored (spec).** `ObjectStackDefinitionSchema` gains a `tiers`
24+
field, so `defineStack` no longer strips it. `config.tiers` (e.g. a list WITHOUT
25+
`ai`) now actually overrides the `--preset` default — previously it was silently
26+
dropped by schema validation, making the `--preset` help text inaccurate. This is
27+
a second, in-place way to disable AI for a deployment without touching dependencies.

examples/app-crm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@objectstack/runtime": "workspace:*",
23+
"@objectstack/service-ai": "workspace:*",
2324
"@objectstack/spec": "workspace:*"
2425
},
2526
"devDependencies": {

examples/app-showcase/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@objectstack/connector-slack": "workspace:*",
2828
"@objectstack/driver-sql": "workspace:*",
2929
"@objectstack/runtime": "workspace:*",
30+
"@objectstack/service-ai": "workspace:*",
3031
"@objectstack/spec": "workspace:*"
3132
},
3233
"devDependencies": {

packages/cli/src/commands/serve.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1416,7 +1416,36 @@ export default class Serve extends Command {
14161416
return import(/* webpackIgnore: true */ pkg);
14171417
}
14181418
};
1419-
if (!hasAIPlugin && tierEnabled('ai')) {
1419+
// [CE AI opt-in] Auto-register the headless AI service ONLY when the host
1420+
// app DECLARES the AI service (or the cloud AI Studio that builds on it).
1421+
// Declaration is the edition boundary: a Community-Edition app that omits
1422+
// both gets no AI service, no
1423+
// agents, and no `services.ai` in discovery (so the console hides its AI
1424+
// surface), while MCP and every other capability are unaffected. Gating on
1425+
// a *declared* dep — not mere resolvability — makes this reliable in a
1426+
// workspace/monorepo, where the package stays hoist-resolvable when undeclared.
1427+
const _fs = await import('node:fs');
1428+
const hostDeclaresDependency = (pkg: string): boolean => {
1429+
try {
1430+
const hostPkg = JSON.parse(
1431+
_fs.readFileSync(_hostRequire.resolve('./package.json'), 'utf8'),
1432+
) as Record<string, Record<string, string> | undefined>;
1433+
return Boolean(
1434+
hostPkg.dependencies?.[pkg] ?? hostPkg.devDependencies?.[pkg]
1435+
?? hostPkg.optionalDependencies?.[pkg] ?? hostPkg.peerDependencies?.[pkg],
1436+
);
1437+
} catch {
1438+
return false;
1439+
}
1440+
};
1441+
// AI Studio (`@objectstack/service-ai-studio`) attaches its personas via the
1442+
// `ai:ready` hook the base service fires, so declaring Studio implies the base
1443+
// service — load it even when only Studio is in the deps (the base is a
1444+
// transitive dep of Studio, so it stays resolvable).
1445+
const wantsAiService =
1446+
hostDeclaresDependency('@objectstack/service-ai')
1447+
|| hostDeclaresDependency('@objectstack/service-ai-studio');
1448+
if (!hasAIPlugin && tierEnabled('ai') && wantsAiService) {
14201449
try {
14211450
const aiPkg = '@objectstack/service-ai';
14221451
const { AIServicePlugin } = await importFromHost(aiPkg);

packages/spec/src/stack.zod.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,13 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({
358358
*/
359359
requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform'),
360360

361+
/**
362+
* Plugin tier presets to auto-register (e.g. `core`, `ai`, `ui`, `auth`).
363+
* Overrides the `--preset` flag; omit to use the preset default. Set a list
364+
* WITHOUT `ai` to run without the AI service (Community-Edition deployments).
365+
*/
366+
tiers: z.array(z.string()).optional().describe('Plugin tier presets to enable; overrides --preset'),
367+
361368
/**
362369
* DevPlugins: Development Capabilities
363370
* List of plugins to load ONLY in development environment.

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)