@sayansr26/agent-os 0.6.0 → 0.6.1

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.
@@ -22,6 +22,17 @@
22
22
  "orchestration",
23
23
  "codebase-navigation"
24
24
  ]
25
+ },
26
+ {
27
+ "name": "auto-route",
28
+ "source": "./plugins/auto-route",
29
+ "description": "Picks the model and effort level for each turn from what the prompt asks for. /route pins a tier or turns routing off.",
30
+ "category": "productivity",
31
+ "keywords": [
32
+ "model-routing",
33
+ "effort",
34
+ "mod"
35
+ ]
25
36
  }
26
37
  ]
27
38
  }
package/CHANGELOG.md CHANGED
@@ -5,7 +5,25 @@ All notable changes to this project are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
  this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.6.0] — 2026-09-25
8
+ ## [0.6.1] — 2026-10-21
9
+
10
+ agent-os plugin unchanged (0.5.0). New plugin auto-route 0.1.0 in the same
11
+ marketplace. 0.7.0 stays reserved for the move to Rulegate.
12
+
13
+ ### Added
14
+ - **auto-route, a second plugin in the `sayan-plugins` marketplace.** A Claude
15
+ Code mod (a TypeScript hooks module) that picks the model and effort for each
16
+ turn from what the prompt asks for: lookups to Haiku, regular coding to
17
+ Sonnet, design and debugging to Opus, "ultrathink" and security work to Opus
18
+ at max effort. Subagents keep their own model. `/route` shows the route,
19
+ `/route light|standard|deep|max` pins a tier, `/route off` and `/route auto`
20
+ switch it off and on. `/plugin install auto-route@sayan-plugins`.
21
+ - **`agent-os init` installs and updates auto-route** at project scope next to
22
+ agent-os, adding or refreshing the marketplace once. `--no-auto-route` leaves
23
+ it out; `--no-plugin` still skips both.
24
+ - `scripts/validate-plugin.mjs` checks every other marketplace plugin built
25
+ from hook modules: manifest, name against its marketplace entry, the types
26
+ contract and each module it names.
9
27
 
10
28
  Plugin 0.5.0. Found by running 0.4.1 in a real project: the plugin passed its
11
29
  own audit while Claude ignored it — a whole feature was built without the
package/README.md CHANGED
@@ -140,6 +140,28 @@ Feature directories default to the first of `src/features`, `src/modules`, `app/
140
140
  { "targets": ["claude-code"], "claude": { "features": ["src/features/*", "apps/*"], "cartographerReminder": true } }
141
141
  ```
142
142
 
143
+ ### auto-route
144
+
145
+ A second, independent plugin in the same marketplace: a Claude Code mod (a TypeScript hooks module) that picks the model and effort for each turn from what the prompt asks for — lookups go to Haiku, regular coding to Sonnet, design and debugging to Opus, "ultrathink" and security work to Opus at max effort. Subagents keep their own model.
146
+
147
+ ```
148
+ /plugin install auto-route@sayan-plugins
149
+ ```
150
+
151
+ `agent-os init` installs and updates it alongside agent-os, at project scope; `--no-auto-route` leaves it out.
152
+
153
+ `/route` shows the current route; `/route light|standard|deep|max` pins a tier, `/route off` hands control back to the session's own model, `/route auto` resumes routing. The tier table and the prompt rules sit at the top of `plugins/auto-route/hooks/register.ts`.
154
+
155
+ Working on it:
156
+
157
+ ```sh
158
+ claude --plugin-dir plugins/auto-route # run a session with the working copy, hot-reloaded
159
+ claude plugin validate plugins/auto-route # what the engine would refuse
160
+ claude plugin test plugins/auto-route # tests/route.test.ts
161
+ ```
162
+
163
+ Once the engine loads it, it writes `.claude-plugin/types/` (git-ignored) for the editor; `tsc -p plugins/auto-route` type-checks it after that. Bump `version` in its `plugin.json` when shipping a change so installs update.
164
+
143
165
  ---
144
166
 
145
167
  ## Design rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayansr26/agent-os",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "One source of truth for AI coding agent config. Write your rules once; compile them to Claude Code, Cursor, Cline, Windsurf, Antigravity, Gemini CLI, OpenCode and Kilo.",
5
5
  "keywords": [
6
6
  "ai",
@@ -46,4 +46,4 @@
46
46
  "test": "node scripts/validate-plugin.mjs && node src/selftest.mjs",
47
47
  "prepublishOnly": "npm test"
48
48
  }
49
- }
49
+ }
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "homepage": "https://github.com/sayansr26/agent-os",
10
10
  "repository": "https://github.com/sayansr26/agent-os",
11
+ "privacyPolicyUrl": "https://s3techpulse.com/privacy-policy",
12
+ "supportUrl": "https://s3techpulse.com/support",
11
13
  "license": "MIT",
12
14
  "keywords": [
13
15
  "context-engineering",
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "auto-route",
3
+ "version": "0.1.0",
4
+ "description": "Picks the model and effort level for each turn from what the prompt asks for",
5
+ "types": "./types/index.d.ts",
6
+ "author": {
7
+ "name": "Sayan Choudhury",
8
+ "email": "sayan.choudhury.in@gmail.com"
9
+ },
10
+ "homepage": "https://github.com/sayansr26/agent-os",
11
+ "repository": "https://github.com/sayansr26/agent-os",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "model-routing",
15
+ "effort",
16
+ "mod"
17
+ ]
18
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.ts"] }
@@ -0,0 +1,143 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { ModelEffort, Register } from 'claude-code'
3
+
4
+ import type { Mode, Route, Tier } from '../types'
5
+
6
+ // Edit this table to change what each tier runs on.
7
+ const TIERS: Record<Tier, { model: string; effort: ModelEffort }> = {
8
+ light: { model: 'claude-haiku-5-5', effort: 'low' },
9
+ standard: { model: 'claude-sonnet-5-5', effort: 'medium' },
10
+ deep: { model: 'claude-opus-5-5', effort: 'high' },
11
+ max: { model: 'claude-opus-5-5', effort: 'max' },
12
+ }
13
+
14
+ // Checked top to bottom; the first tier whose pattern matches wins.
15
+ const RULES: { tier: Tier; reason: string; pattern: RegExp }[] = [
16
+ {
17
+ tier: 'max',
18
+ reason: 'asked to think hard',
19
+ pattern: /\b(ultrathink|think (really )?hard|think harder|deep dive)\b/i,
20
+ },
21
+ {
22
+ tier: 'max',
23
+ reason: 'security or concurrency',
24
+ pattern:
25
+ /\b(security (audit|review)|vulnerabilit|race condition|deadlock|memory leak|incident|data loss)/i,
26
+ },
27
+ {
28
+ tier: 'deep',
29
+ reason: 'design or debugging',
30
+ pattern:
31
+ /\b(architect|design|refactor|migrat|debug|root cause|investigate|why (is|does|doesn't|isn't)|plan|implement|build (a|the|an)|new feature|end[- ]to[- ]end|across|whole (repo|codebase|app)|optimi[sz]e|performance|review)/i,
32
+ },
33
+ {
34
+ tier: 'light',
35
+ reason: 'small or lookup task',
36
+ pattern:
37
+ /^(what|which|where|who|when|is|are|does|do|can|show|list|print|explain briefly|rename|fix (the )?typo|format|lint|bump|commit message|thanks|thank you|ok|yes|no)\b/i,
38
+ },
39
+ ]
40
+
41
+ export const classify = (text: string): Route => {
42
+ const words = text.trim().split(/\s+/).filter(Boolean).length
43
+ const rule = RULES.find(one => one.pattern.test(text))
44
+
45
+ if (rule && !(rule.tier === 'light' && words > 40)) {
46
+ return { tier: rule.tier, reason: rule.reason }
47
+ }
48
+
49
+ if (words > 150) {
50
+ return { tier: 'deep', reason: 'long, detailed prompt' }
51
+ }
52
+
53
+ return words <= 8
54
+ ? { tier: 'light', reason: 'short prompt' }
55
+ : { tier: 'standard', reason: 'regular coding task' }
56
+ }
57
+
58
+ const MODES: readonly Mode[] = ['auto', 'off', 'light', 'standard', 'deep', 'max']
59
+
60
+ const mode = atom({ plugin: 'auto-route', key: 'mode' } as const, 'auto')
61
+ const route = atom({ plugin: 'auto-route', key: 'route' } as const, null)
62
+
63
+ const describe = (current: Mode, last: Route | null) => {
64
+ if (current === 'off') {
65
+ return 'auto-route: off'
66
+ }
67
+
68
+ const pinned = current === 'auto' ? '' : ' (pinned)'
69
+ const tier = current === 'auto' ? last?.tier : current
70
+
71
+ if (!tier) {
72
+ return 'auto-route: waiting for a prompt'
73
+ }
74
+
75
+ const { model, effort } = TIERS[tier]
76
+
77
+ return `auto-route: ${tier}${pinned} · ${model.replace(/^claude-/, '')} / ${effort}`
78
+ }
79
+
80
+ export const register: Register = on => {
81
+ on('session.start', async ($, e, next) => {
82
+ await $.command.register({
83
+ name: 'route',
84
+ description: 'Auto model routing: auto, off, or pin light | standard | deep | max',
85
+ })
86
+
87
+ return next(e)
88
+ })
89
+
90
+ on('command.run', { command: 'route' }, async ($, e) => {
91
+ const asked = e.args.trim().toLowerCase()
92
+
93
+ if (asked !== '') {
94
+ const next = MODES.find(one => one === asked)
95
+
96
+ if (!next) {
97
+ return { text: `auto-route: unknown mode "${asked}". Use ${MODES.join(', ')}.` }
98
+ }
99
+
100
+ await update($, mode, () => next)
101
+ }
102
+
103
+ const text = describe(await read($, mode), await read($, route))
104
+ $.ui.status(text)
105
+
106
+ return { text: text.replace(/^auto-route: /, '') }
107
+ })
108
+
109
+ on('prompt.submit', async ($, e, next) => {
110
+ // A prompt delivered into a running turn keeps that turn's route.
111
+ const current = await read($, mode)
112
+ const isRouted = e.turnId === undefined && !e.command && current === 'auto'
113
+ $.ui.log(`auto-route: prompt.submit mode=${current} turnId=${e.turnId ?? '-'} command=${e.command ? 'yes' : 'no'} routed=${isRouted}`)
114
+
115
+ if (isRouted) {
116
+ const picked = classify(e.text)
117
+ await update($, route, () => picked)
118
+ $.ui.status(describe('auto', picked))
119
+ }
120
+
121
+ return next(e)
122
+ }).catch(($, e, next) => {
123
+ $.ui.log('auto-route: prompt.submit hook failed')
124
+
125
+ return next(e)
126
+ })
127
+
128
+ on('turn.step', async function* ($, e, next) {
129
+ const current = await read($, mode)
130
+ const last = await read($, route)
131
+ const tier = current === 'auto' ? last?.tier : current === 'off' ? undefined : current
132
+
133
+ // Subagents choose their own model; leave their requests alone.
134
+ if (e.agentId !== undefined || !tier) {
135
+ return yield* next(e)
136
+ }
137
+
138
+ const { model, effort } = TIERS[tier]
139
+ $.ui.log(`auto-route: turn.step ${e.model}/${e.effort ?? '-'} -> ${model}/${effort}`)
140
+
141
+ return yield* next({ ...e, model, effort })
142
+ })
143
+ }
@@ -0,0 +1,87 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+ import type { HookStream, TurnStepChunk, TurnStepInput, TurnStepResult } from 'claude-code'
3
+
4
+ import { classify } from '../hooks/register'
5
+
6
+ const STEP: TurnStepInput = {
7
+ turnId: 't1',
8
+ index: 0,
9
+ model: 'claude-opus-5-5',
10
+ effort: 'high',
11
+ messageCount: 1,
12
+ }
13
+
14
+ const ORIGIN = { kind: 'composer' } as const
15
+
16
+ const prompt = (text: string) => ({ text, wait: false, origin: ORIGIN })
17
+
18
+ const route = (args: string) => ({
19
+ command: 'route',
20
+ args,
21
+ origin: ORIGIN,
22
+ presentation: { isFullscreen: false, columns: 120 },
23
+ })
24
+
25
+ // Reads the request's stream to its end, as the engine would.
26
+ type Steps = { turn: { step: (e: TurnStepInput) => HookStream<TurnStepChunk, TurnStepResult> } }
27
+
28
+ const step = async ($: Steps, e: TurnStepInput) => {
29
+ const stream = $.turn.step(e)
30
+ for await (const _ of stream) {
31
+ }
32
+ return stream.result
33
+ }
34
+
35
+ test('prompts land in the expected tier', async () => {
36
+ expect(classify('what does this function return?').tier).toBe('light')
37
+ expect(classify('ok').tier).toBe('light')
38
+ expect(
39
+ classify('add a loading spinner to the submit button on the ask form and disable it while saving').tier,
40
+ ).toBe('standard')
41
+ expect(classify('refactor the fix ranking so it weighs environment matches').tier).toBe('deep')
42
+ expect(classify('debug why the MCP search is slow').tier).toBe('deep')
43
+ expect(classify('ultrathink about the verification model').tier).toBe('max')
44
+ expect(classify('is there a race condition in the release watcher?').tier).toBe('max')
45
+ })
46
+
47
+ test('the routed model and effort reach the request, subagents untouched', async ($, on) => {
48
+ const sent: TurnStepInput[] = []
49
+ on('ui.status', () => ({ value: undefined }))
50
+ on('prompt.submit', (_, e) => ({ text: e.text }))
51
+ on('turn.step', async function* (_, e) {
52
+ sent.push(e)
53
+ return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
54
+ })
55
+
56
+ await $.prompt.submit(prompt('what is in package.json?'))
57
+ await step($, STEP)
58
+ await step($, { ...STEP, agentId: 'a1' })
59
+
60
+ expect(sent[0]?.model).toBe('claude-haiku-5-5')
61
+ expect(sent[0]?.effort).toBe('low')
62
+ expect(sent[1]?.model).toBe('claude-opus-5-5')
63
+ expect(sent[1]?.effort).toBe('high')
64
+ })
65
+
66
+ test('/route pins a tier and off stops routing', async ($, on) => {
67
+ const sent: TurnStepInput[] = []
68
+ on('command.register', () => ({ value: { command: 'route' } }))
69
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
70
+ on('ui.status', () => ({ value: undefined }))
71
+ on('prompt.submit', (_, e) => ({ text: e.text }))
72
+ on('turn.step', async function* (_, e) {
73
+ sent.push(e)
74
+ return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
75
+ })
76
+
77
+ await $.session.start({ cwd: '/', surface: 'terminal', isInteractive: true })
78
+ await $.command.run(route('max'))
79
+ await $.prompt.submit(prompt('ok'))
80
+ await step($, STEP)
81
+ expect(sent[0]?.effort).toBe('max')
82
+
83
+ await $.command.run(route('off'))
84
+ await step($, { ...STEP, model: 'claude-sonnet-5-5', effort: 'medium' })
85
+ expect(sent[1]?.model).toBe('claude-sonnet-5-5')
86
+ expect(sent[1]?.effort).toBe('medium')
87
+ })
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -0,0 +1,12 @@
1
+ export type Tier = 'light' | 'standard' | 'deep' | 'max'
2
+
3
+ // 'auto' classifies each prompt; a tier pins it; 'off' leaves the session's own.
4
+ export type Mode = 'auto' | 'off' | Tier
5
+
6
+ export type Route = { tier: Tier; reason: string }
7
+
8
+ declare module 'claude-code' {
9
+ interface PluginState {
10
+ 'auto-route': { mode: Mode; route: Route | null }
11
+ }
12
+ }
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { detect, summarise, TOOLS } from "./detect.mjs";
6
6
  import { load, write, readIfExists, matches, BANNER, DIR } from "./source.mjs";
7
7
  import { adopt } from "./adopt.mjs";
8
- import { ensurePlugin, MARKETPLACE_NAME, PLUGIN } from "./plugin.mjs";
8
+ import { ensurePlugin, MARKETPLACE_NAME, PLUGIN, PLUGINS } from "./plugin.mjs";
9
9
  import { setupState, describeState } from "../plugins/agent-os/skills/init/scripts/state.mjs";
10
10
  import { applyScope, describe } from "./claude-setup.mjs";
11
11
  import { createInterface } from "node:readline/promises";
@@ -33,7 +33,9 @@ Options
33
33
  --root <dir> project directory (default: cwd)
34
34
  --dry-run print what would change, write nothing
35
35
  --force overwrite files agent-os did not generate (it refuses by default)
36
- --no-plugin skip installing or updating the Claude Code plugin during init
36
+ --no-plugin skip installing or updating the Claude Code plugins during init
37
+ --no-auto-route
38
+ init: install agent-os but not the auto-route model router
37
39
  --global init: also write ~/.claude settings without asking
38
40
  --no-global init: leave ~/.claude alone
39
41
  `;
@@ -265,19 +267,25 @@ Pick one:
265
267
  }
266
268
 
267
269
  if (cmd === "init" && targets.includes("claude-code") && !argv.includes("--no-plugin")) {
268
- console.log(`\n${bold("Claude Code plugin")} ${dim(`${PLUGIN}@${MARKETPLACE_NAME}`)}\n`);
269
- const r = ensurePlugin({ root, dry });
270
- for (const line of r.done) console.log(` ${dim(line)}`);
271
- if (r.ok) console.log({
272
- installed: `\n installed ${r.to || ""} at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`,
273
- updated: `\n updated ${r.from} → ${r.to}`,
274
- current: `\n already the latest (${r.to})`,
275
- "dry-run": `\n ${dim(r.from ? `installed: ${r.from} — would refresh and update` : "not installed — would install")}`,
276
- }[r.reason]);
277
- for (const line of r.hint) console.log(` ${r.ok ? dim(line) : line}`);
278
- if (r.reason === "installed" || r.reason === "updated")
279
- console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
280
- console.log(`\n ${dim("--no-plugin skips this")}`);
270
+ // Both come from the same marketplace, so it is added or refreshed once:
271
+ // by the first plugin whose run gets that far.
272
+ let refresh = true;
273
+ for (const plugin of PLUGINS.filter((p) => p === PLUGIN || !argv.includes(`--no-${p}`))) {
274
+ console.log(`\n${bold("Claude Code plugin")} ${dim(`${plugin}@${MARKETPLACE_NAME}`)}\n`);
275
+ const r = ensurePlugin({ root, dry, plugin, refresh });
276
+ if (r.done.length || r.reason === "no-cli") refresh = false;
277
+ for (const line of r.done) console.log(` ${dim(line)}`);
278
+ if (r.ok) console.log({
279
+ installed: `\n installed ${r.to || ""} at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`,
280
+ updated: `\n updated ${r.from} → ${r.to}`,
281
+ current: `\n already the latest (${r.to})`,
282
+ "dry-run": `\n ${dim(r.from ? `installed: ${r.from} — would refresh and update` : "not installed — would install")}`,
283
+ }[r.reason]);
284
+ for (const line of r.hint) console.log(` ${r.ok ? dim(line) : line}`);
285
+ if (r.reason === "installed" || r.reason === "updated")
286
+ console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
287
+ }
288
+ console.log(`\n ${dim("--no-plugin skips this; --no-auto-route skips only the model router")}`);
281
289
  }
282
290
 
283
291
  if (cmd === "init" && !dry) {
package/src/plugin.mjs CHANGED
@@ -2,8 +2,10 @@
2
2
  * Install or update the Claude Code half of agent-os during `init`.
3
3
  *
4
4
  * The CLI compiles rules for every tool. Claude Code additionally gets the
5
- * agents, skills, per-agent memory and hooks, which ship as a plugin. `init`
6
- * brings that plugin to the latest version whatever state it finds:
5
+ * agents, skills, per-agent memory and hooks, which ship as a plugin, and
6
+ * auto-route, a mod that picks the model and effort per turn. Both live in
7
+ * this repo's marketplace. `init` brings each to the latest version whatever
8
+ * state it finds:
7
9
  *
8
10
  * not installed marketplace add + install, at project scope
9
11
  * installed, disabled enable
@@ -19,18 +21,25 @@
19
21
  * on. The rules are the part that must not depend on this.
20
22
  */
21
23
  import { spawnSync } from "node:child_process";
22
- import { realpathSync } from "node:fs";
23
- import { bundledVersion, cmpVersion } from "../plugins/agent-os/skills/init/scripts/state.mjs";
24
+ import { readFileSync, realpathSync } from "node:fs";
25
+ import { cmpVersion } from "../plugins/agent-os/skills/init/scripts/state.mjs";
24
26
 
25
27
  export const MARKETPLACE = "sayansr26/agent-os";
26
28
  export const MARKETPLACE_NAME = "sayan-plugins";
27
29
  export const PLUGIN = "agent-os";
28
- const ID = `${PLUGIN}@${MARKETPLACE_NAME}`;
30
+ // Installed by `init` in this order; each can be skipped with --no-<name>.
31
+ export const PLUGINS = [PLUGIN, "auto-route"];
32
+ const idOf = (plugin) => `${plugin}@${MARKETPLACE_NAME}`;
29
33
 
30
34
  const run = (args, cwd) =>
31
35
  spawnSync("claude", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
32
36
  const json = (r) => { try { return JSON.parse(r.stdout); } catch { return null; } };
33
37
  const real = (p) => { try { return realpathSync(p); } catch { return p; } };
38
+ /** The version this package ships for a plugin, from its plugin.json. */
39
+ const bundled = (plugin) => {
40
+ try { return JSON.parse(readFileSync(new URL(`../plugins/${plugin}/.claude-plugin/plugin.json`, import.meta.url), "utf8")).version || null; }
41
+ catch { return null; }
42
+ };
34
43
  const tail = (r) => (r.stderr || r.stdout || String(r.error || "")).trim().split("\n").slice(-3);
35
44
 
36
45
  /** Is the Claude Code CLI usable from here? */
@@ -39,40 +48,46 @@ export function claudeAvailable() {
39
48
  return !r.error && r.status === 0;
40
49
  }
41
50
 
42
- /** This project's install of the plugin, per `claude plugin list --json`, or null. */
43
- export function installed(root) {
51
+ /** This project's install of a plugin, per `claude plugin list --json`, or null. */
52
+ export function installed(root, plugin = PLUGIN) {
44
53
  const list = json(run(["plugin", "list", "--json"], root));
45
54
  if (!Array.isArray(list)) return null;
46
- const mine = list.filter((p) => p.id === ID &&
55
+ const mine = list.filter((p) => p.id === idOf(plugin) &&
47
56
  (p.scope === "user" || (p.projectPath && real(p.projectPath) === real(root))));
48
57
  return mine.find((p) => p.scope === "project" || p.scope === "local") || mine[0] || null;
49
58
  }
50
59
 
51
60
  /**
52
- * Bring the plugin to the latest version for this project.
61
+ * Bring a plugin to the latest version for this project.
53
62
  * Returns { ok, reason, from, to, done, hint } with reason one of
54
63
  * "installed" | "updated" | "current" | "dry-run" | "no-cli" | "failed".
64
+ * `refresh: false` skips the marketplace step when an earlier call this run
65
+ * already added or updated it.
55
66
  */
56
- export function ensurePlugin({ root = process.cwd(), dry = false, scope = "project" } = {}) {
67
+ export function ensurePlugin({ root = process.cwd(), dry = false, scope = "project", plugin = PLUGIN, refresh = true } = {}) {
68
+ const ID = idOf(plugin);
57
69
  if (!claudeAvailable())
58
70
  return {
59
71
  ok: false, reason: "no-cli", done: [],
60
72
  hint: [
61
- "The `claude` CLI is not on PATH, so the plugin was not installed or updated.",
62
- "Inside Claude Code, run:",
63
- ` /plugin marketplace add ${MARKETPLACE}`,
73
+ ...(refresh ? [
74
+ "The `claude` CLI is not on PATH, so the plugin was not installed or updated.",
75
+ "Inside Claude Code, run:",
76
+ ` /plugin marketplace add ${MARKETPLACE}`,
77
+ ] : []),
64
78
  ` /plugin install ${ID} (or /plugin update ${ID} if it is already there)`,
65
79
  ],
66
80
  };
67
81
 
68
- const before = installed(root);
69
- const markets = json(run(["plugin", "marketplace", "list", "--json"], root));
70
- const haveMarket = Array.isArray(markets) && markets.some((m) => m.name === MARKETPLACE_NAME);
71
-
82
+ const before = installed(root, plugin);
72
83
  const steps = [];
73
- steps.push(haveMarket
74
- ? ["plugin", "marketplace", "update", MARKETPLACE_NAME]
75
- : ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]);
84
+ if (refresh) {
85
+ const markets = json(run(["plugin", "marketplace", "list", "--json"], root));
86
+ const haveMarket = Array.isArray(markets) && markets.some((m) => m.name === MARKETPLACE_NAME);
87
+ steps.push(haveMarket
88
+ ? ["plugin", "marketplace", "update", MARKETPLACE_NAME]
89
+ : ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]);
90
+ }
76
91
  if (!before) steps.push(["plugin", "install", ID, "--scope", scope, "--yes"]);
77
92
  else {
78
93
  if (before.enabled === false) steps.push(["plugin", "enable", ID, "--scope", before.scope]);
@@ -99,14 +114,14 @@ export function ensurePlugin({ root = process.cwd(), dry = false, scope = "proje
99
114
  done.push(`claude ${args.join(" ")}`);
100
115
  }
101
116
 
102
- const after = installed(root);
117
+ const after = installed(root, plugin);
103
118
  const to = after?.version || null;
104
119
  const from = before?.version || null;
105
- const want = bundledVersion();
120
+ const want = bundled(plugin);
106
121
  const hint = [];
107
122
  // The marketplace serves what is pushed to GitHub; this package may be newer.
108
123
  if (to && want && cmpVersion(to, want) < 0)
109
- hint.push(`Marketplace still serves ${to}; this agent-os ships ${want}. It updates once ${MARKETPLACE} is pushed.`);
124
+ hint.push(`Marketplace still serves ${plugin} ${to}; this agent-os ships ${want}. It updates once ${MARKETPLACE} is pushed.`);
110
125
  return {
111
126
  ok: true,
112
127
  reason: !before ? "installed" : from !== to ? "updated" : "current",
package/src/selftest.mjs CHANGED
@@ -101,6 +101,11 @@ try {
101
101
  ok(pi.stdout.includes("SETUP FRESH"), "init on an empty project reports a fresh setup");
102
102
  ok(/marketplace add sayansr26\/agent-os|plugin marketplace add|not on PATH/.test(pi.stdout),
103
103
  "init names the marketplace step or says why it could not run it");
104
+ ok(pi.stdout.includes("auto-route@sayan-plugins"), "init sets up auto-route by default");
105
+ ok((pi.stdout.match(/marketplace add/g) || []).length <= 1, "the marketplace is added once, not per plugin");
106
+ const na = cli(["init", "--dry-run", "--no-auto-route"], r4);
107
+ ok(!/install auto-route@sayan-plugins/.test(na.stdout) && /install agent-os@sayan-plugins/.test(na.stdout),
108
+ "--no-auto-route keeps agent-os and skips auto-route");
104
109
  rmSync(r4, { recursive: true, force: true });
105
110
 
106
111
  // A hand-written file at a generated path must survive a sync.