@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.
- package/.claude-plugin/marketplace.json +11 -0
- package/CHANGELOG.md +19 -1
- package/README.md +22 -0
- package/package.json +2 -2
- package/plugins/agent-os/.claude-plugin/icon.png +0 -0
- package/plugins/agent-os/.claude-plugin/plugin.json +2 -0
- package/plugins/auto-route/.claude-plugin/plugin.json +18 -0
- package/plugins/auto-route/hooks/hooks.json +1 -0
- package/plugins/auto-route/hooks/register.ts +143 -0
- package/plugins/auto-route/tests/route.test.ts +87 -0
- package/plugins/auto-route/tsconfig.json +3 -0
- package/plugins/auto-route/types/index.d.ts +12 -0
- package/src/cli.mjs +23 -15
- package/src/plugin.mjs +38 -23
- package/src/selftest.mjs +5 -0
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|
|
Binary file
|
|
@@ -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,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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 =
|
|
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.
|