@sentroy-co/client-sdk 2.13.9 → 2.15.0

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.
Files changed (49) hide show
  1. package/README.md +53 -5
  2. package/dist/auth/client.d.ts.map +1 -1
  3. package/dist/auth/client.js +8 -1
  4. package/dist/auth/client.js.map +1 -1
  5. package/dist/auth/react/index.js +1 -1
  6. package/dist/auth/react/index.js.map +1 -1
  7. package/dist/auth/react-native/index.d.ts +93 -0
  8. package/dist/auth/react-native/index.d.ts.map +1 -0
  9. package/dist/auth/react-native/index.js +106 -0
  10. package/dist/auth/react-native/index.js.map +1 -0
  11. package/dist/cli/ai.d.ts +35 -0
  12. package/dist/cli/ai.d.ts.map +1 -0
  13. package/dist/cli/ai.js +399 -0
  14. package/dist/cli/ai.js.map +1 -0
  15. package/dist/cli/args.d.ts +62 -0
  16. package/dist/cli/args.d.ts.map +1 -0
  17. package/dist/cli/args.js +199 -0
  18. package/dist/cli/args.js.map +1 -0
  19. package/dist/cli/env.d.ts.map +1 -1
  20. package/dist/cli/env.js +8 -2
  21. package/dist/cli/env.js.map +1 -1
  22. package/dist/cli/format.d.ts +37 -0
  23. package/dist/cli/format.d.ts.map +1 -0
  24. package/dist/cli/format.js +129 -0
  25. package/dist/cli/format.js.map +1 -0
  26. package/dist/cli/index.d.ts +8 -2
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +128 -25
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/mail.d.ts +25 -0
  31. package/dist/cli/mail.d.ts.map +1 -0
  32. package/dist/cli/mail.js +253 -0
  33. package/dist/cli/mail.js.map +1 -0
  34. package/dist/cli/storage.d.ts +28 -0
  35. package/dist/cli/storage.d.ts.map +1 -0
  36. package/dist/cli/storage.js +189 -0
  37. package/dist/cli/storage.js.map +1 -0
  38. package/package.json +9 -2
  39. package/skill/SKILL.md +577 -0
  40. package/src/auth/client.ts +8 -1
  41. package/src/auth/react/index.tsx +1 -1
  42. package/src/auth/react-native/index.ts +157 -0
  43. package/src/cli/ai.ts +440 -0
  44. package/src/cli/args.ts +225 -0
  45. package/src/cli/env.ts +10 -2
  46. package/src/cli/format.ts +147 -0
  47. package/src/cli/index.ts +147 -25
  48. package/src/cli/mail.ts +363 -0
  49. package/src/cli/storage.ts +307 -0
@@ -0,0 +1,157 @@
1
+ import type { AuthStorageAdapter } from "../client"
2
+
3
+ /** AsyncStorage instance interface — matches @react-native-async-storage/async-storage */
4
+ interface AsyncStorageLike {
5
+ getItem(key: string): Promise<string | null>
6
+ setItem(key: string, value: string): Promise<void>
7
+ removeItem(key: string): Promise<void>
8
+ }
9
+
10
+ /** SecureStore instance interface — matches expo-secure-store. */
11
+ interface SecureStoreLike {
12
+ getItemAsync(key: string): Promise<string | null>
13
+ setItemAsync(key: string, value: string): Promise<void>
14
+ deleteItemAsync(key: string): Promise<void>
15
+ }
16
+
17
+ interface AdapterValue {
18
+ accessToken: string
19
+ refreshToken: string
20
+ user: unknown // SentroyAuthUser shape, but kept loose to avoid circular import burden
21
+ }
22
+
23
+ export interface CreateAsyncAdapterOptions {
24
+ /** Project slug — used to namespace the storage key. */
25
+ projectSlug: string
26
+ /** Optional custom key prefix. Default "sentroy.auth". */
27
+ keyPrefix?: string
28
+ }
29
+
30
+ /**
31
+ * Wrap React Native AsyncStorage in the synchronous AuthStorageAdapter
32
+ * contract via a hydrate-on-construct + cache-in-memory + write-through
33
+ * pattern. Reads always hit the cache (sync); writes both update the cache
34
+ * and fire-and-forget to AsyncStorage.
35
+ *
36
+ * Usage:
37
+ * import AsyncStorage from "@react-native-async-storage/async-storage"
38
+ * import { createAsyncStorageAdapter } from "@sentroy-co/client-sdk/auth/react-native"
39
+ *
40
+ * const adapter = createAsyncStorageAdapter(AsyncStorage, { projectSlug: "acme" })
41
+ * const auth = new SentroyAuth({ projectSlug: "acme", apiKey: "...", storage: adapter })
42
+ * // Hydration completes asynchronously; auth.user is null until then.
43
+ * // SentroyAuthProvider's `loading` flag covers this — render a splash.
44
+ *
45
+ * await adapter.ready // optional: await before reading auth.user directly
46
+ */
47
+ export function createAsyncStorageAdapter(
48
+ storage: AsyncStorageLike,
49
+ opts: CreateAsyncAdapterOptions,
50
+ ): AuthStorageAdapter & { ready: Promise<void> } {
51
+ const key = `${opts.keyPrefix ?? "sentroy.auth"}.${opts.projectSlug}`
52
+ let cache: AdapterValue | null = null
53
+ const ready = (async () => {
54
+ try {
55
+ const raw = await storage.getItem(key)
56
+ if (raw) cache = JSON.parse(raw) as AdapterValue
57
+ } catch {
58
+ cache = null
59
+ }
60
+ })()
61
+ return {
62
+ read() { return cache as ReturnType<AuthStorageAdapter["read"]> },
63
+ write(value) {
64
+ cache = value as AdapterValue
65
+ void storage.setItem(key, JSON.stringify(value)).catch(() => {})
66
+ },
67
+ clear() {
68
+ cache = null
69
+ void storage.removeItem(key).catch(() => {})
70
+ },
71
+ ready,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Wrap expo-secure-store in the synchronous AuthStorageAdapter contract.
77
+ * Use this for the refreshToken — it's a long-lived credential and lives
78
+ * better in the OS keychain/keystore than in AsyncStorage.
79
+ *
80
+ * Note: expo-secure-store has a 2KB per-key size limit on iOS. If the user
81
+ * blob exceeds it, split: keep refreshToken in SecureStore via this adapter,
82
+ * keep accessToken+user in AsyncStorage via `createAsyncStorageAdapter`,
83
+ * or strip user.metadata before storing.
84
+ *
85
+ * Usage:
86
+ * import * as SecureStore from "expo-secure-store"
87
+ * import { createSecureStoreAdapter } from "@sentroy-co/client-sdk/auth/react-native"
88
+ *
89
+ * const adapter = createSecureStoreAdapter(SecureStore, { projectSlug: "acme" })
90
+ * const auth = new SentroyAuth({ projectSlug: "acme", apiKey: "...", storage: adapter })
91
+ */
92
+ export function createSecureStoreAdapter(
93
+ store: SecureStoreLike,
94
+ opts: CreateAsyncAdapterOptions,
95
+ ): AuthStorageAdapter & { ready: Promise<void> } {
96
+ const key = `${opts.keyPrefix ?? "sentroy.auth"}.${opts.projectSlug}`
97
+ let cache: AdapterValue | null = null
98
+ const ready = (async () => {
99
+ try {
100
+ const raw = await store.getItemAsync(key)
101
+ if (raw) cache = JSON.parse(raw) as AdapterValue
102
+ } catch {
103
+ cache = null
104
+ }
105
+ })()
106
+ return {
107
+ read() { return cache as ReturnType<AuthStorageAdapter["read"]> },
108
+ write(value) {
109
+ cache = value as AdapterValue
110
+ void store.setItemAsync(key, JSON.stringify(value)).catch(() => {})
111
+ },
112
+ clear() {
113
+ cache = null
114
+ void store.deleteItemAsync(key).catch(() => {})
115
+ },
116
+ ready,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Helper for the expo-web-browser social-login pattern. The auth provider
122
+ * launches the system browser, redirects to your registered `redirectUri`,
123
+ * and returns the tokens via URL hash fragment.
124
+ *
125
+ * Usage:
126
+ * import * as WebBrowser from "expo-web-browser"
127
+ * import { openSocialAuthSession } from "@sentroy-co/client-sdk/auth/react-native"
128
+ *
129
+ * const result = await openSocialAuthSession(WebBrowser, {
130
+ * authorizeUrl: auth.socialAuthorizeUrl("google", { redirectUri: "myapp://auth/callback" }),
131
+ * redirectUri: "myapp://auth/callback",
132
+ * })
133
+ * if (result?.tokens) {
134
+ * await auth.setSession(result.tokens)
135
+ * }
136
+ *
137
+ * Returns null if the user cancelled, or { tokens, user } if successful.
138
+ */
139
+ export interface WebBrowserLike {
140
+ openAuthSessionAsync(url: string, redirectUri: string): Promise<{ type: string; url?: string }>
141
+ }
142
+
143
+ export async function openSocialAuthSession(
144
+ webBrowser: WebBrowserLike,
145
+ opts: { authorizeUrl: string; redirectUri: string },
146
+ ): Promise<{ accessToken: string; refreshToken: string } | null> {
147
+ const result = await webBrowser.openAuthSessionAsync(opts.authorizeUrl, opts.redirectUri)
148
+ if (result.type !== "success" || !result.url) return null
149
+ // Parse the hash fragment (#access_token=...&refresh_token=...&...)
150
+ const u = new URL(result.url)
151
+ const fragment = u.hash.replace(/^#/, "")
152
+ const params = new URLSearchParams(fragment)
153
+ const accessToken = params.get("access_token")
154
+ const refreshToken = params.get("refresh_token")
155
+ if (!accessToken || !refreshToken) return null
156
+ return { accessToken, refreshToken }
157
+ }
package/src/cli/ai.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * `sentroy ai install` — install the Sentroy SDK skill into AI coding
3
+ * assistants (Claude Code, Cursor, Windsurf) and/or a universal
4
+ * AGENTS.md file at the project root.
5
+ *
6
+ * Detection rules (autodetect mode, no explicit target flags):
7
+ * - `.claude/` dir OR `CLAUDE.md` file present → install Claude target
8
+ * - `.cursor/` dir present → install Cursor target
9
+ * - `.windsurfrules` file present → install Windsurf target
10
+ * - none of the above → fall back to AGENTS.md
11
+ *
12
+ * Explicit flags override detection:
13
+ * --claude / --cursor / --windsurf / --agents → install only those
14
+ * --all → install every target
15
+ * --no-agents → suppress AGENTS.md fallback
16
+ *
17
+ * Re-install / upgrade:
18
+ * Each merge-capable target wraps its body in a versioned sentinel block
19
+ * so future installs can find and replace it in-place. --upgrade refuses
20
+ * to write when the installed version already matches the bundled one.
21
+ *
22
+ * Dry-run:
23
+ * --check prints the plan (created / updated / unchanged / skipped) and
24
+ * exits without touching the filesystem.
25
+ *
26
+ * Source override:
27
+ * --source <path> loads SKILL.md from a local file instead of the
28
+ * bundled `<package>/skill/SKILL.md` shipped with the npm package.
29
+ */
30
+
31
+ import * as fs from "fs"
32
+ import * as path from "path"
33
+ import { parseFlags, fail, info, ok, warn, c } from "./args"
34
+
35
+ // ── Constants ────────────────────────────────────────────────────────────
36
+
37
+ const SENTINEL_BEGIN = "<!-- sentroy-skill-begin"
38
+ const SENTINEL_END = "<!-- sentroy-skill-end -->"
39
+
40
+ type Target = "claude" | "cursor" | "windsurf" | "agents"
41
+
42
+ interface BundledSkill {
43
+ raw: string
44
+ version: string
45
+ }
46
+
47
+ interface WriteResult {
48
+ status: "created" | "updated" | "unchanged" | "skipped"
49
+ path: string
50
+ note?: string
51
+ }
52
+
53
+ // ── Bundled skill loader ─────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Load the canonical SKILL.md. By default reads from the package's
57
+ * `skill/SKILL.md` (sibling of `dist/`). With --source, reads any
58
+ * local file the caller points at.
59
+ *
60
+ * Version resolution priority:
61
+ * 1. `version:` field in YAML frontmatter (preferred — single source of truth)
62
+ * 2. footer comment `<!-- skill-version: x.y.z -->`
63
+ * 3. package.json version of the SDK (fallback — always present)
64
+ */
65
+ function readBundledSkill(sourceFlag?: string): BundledSkill {
66
+ const resolved = sourceFlag
67
+ ? path.resolve(process.cwd(), sourceFlag)
68
+ : path.join(__dirname, "..", "..", "skill", "SKILL.md")
69
+
70
+ if (!fs.existsSync(resolved)) {
71
+ fail(
72
+ sourceFlag
73
+ ? `--source path not found: ${resolved}`
74
+ : `bundled SKILL.md missing at ${resolved}. Reinstall @sentroy-co/client-sdk.`,
75
+ )
76
+ }
77
+ const raw = fs.readFileSync(resolved, "utf8")
78
+ const version = parseSkillVersion(raw) ?? readPackageVersion()
79
+ return { raw, version }
80
+ }
81
+
82
+ function parseSkillVersion(raw: string): string | null {
83
+ // Try frontmatter first.
84
+ const fm = extractFrontmatter(raw)
85
+ if (fm) {
86
+ const m = fm.match(/^\s*version\s*:\s*['"]?([^'"\n]+)['"]?\s*$/m)
87
+ if (m) return m[1]!.trim()
88
+ }
89
+ // Fallback: footer/anywhere comment.
90
+ const c2 = raw.match(/<!--\s*skill-version:\s*([^\s>]+)\s*-->/i)
91
+ if (c2) return c2[1]!.trim()
92
+ return null
93
+ }
94
+
95
+ function readPackageVersion(): string {
96
+ try {
97
+ // From dist/cli/ai.js → ../../package.json. From src during tests too.
98
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
99
+ const pkg = require(path.join(__dirname, "..", "..", "package.json")) as {
100
+ version: string
101
+ }
102
+ return pkg.version
103
+ } catch {
104
+ return "0.0.0"
105
+ }
106
+ }
107
+
108
+ // ── Frontmatter helpers ──────────────────────────────────────────────────
109
+
110
+ function extractFrontmatter(raw: string): string | null {
111
+ if (!raw.startsWith("---")) return null
112
+ // Match a leading `---\n...\n---` block. Use a non-greedy capture.
113
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
114
+ return m ? m[1]! : null
115
+ }
116
+
117
+ function stripFrontmatter(raw: string): string {
118
+ if (!raw.startsWith("---")) return raw
119
+ const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
120
+ if (!m) return raw
121
+ return raw.slice(m[0].length).replace(/^\r?\n+/, "")
122
+ }
123
+
124
+ // ── Sentinel block helpers (load-bearing for re-installs) ────────────────
125
+
126
+ function makeSentinelBlock(body: string, version: string): string {
127
+ const header = `${SENTINEL_BEGIN} v${version} -->`
128
+ // Always terminate with a single trailing newline so concatenation in
129
+ // merged files stays tidy across re-installs.
130
+ return `${header}\n${body.trimEnd()}\n${SENTINEL_END}\n`
131
+ }
132
+
133
+ function findSentinelBlock(
134
+ contents: string,
135
+ ): { start: number; end: number; version: string | null } | null {
136
+ const beginRe = /<!--\s*sentroy-skill-begin(?:\s+v([^\s>]+))?\s*-->/
137
+ const beginMatch = beginRe.exec(contents)
138
+ if (!beginMatch) return null
139
+ const start = beginMatch.index
140
+ const endIdx = contents.indexOf(SENTINEL_END, start)
141
+ if (endIdx < 0) return null
142
+ const end = endIdx + SENTINEL_END.length
143
+ return { start, end, version: beginMatch[1] ?? null }
144
+ }
145
+
146
+ function getInstalledVersion(filePath: string): string | null {
147
+ if (!fs.existsSync(filePath)) return null
148
+ const raw = fs.readFileSync(filePath, "utf8")
149
+ const block = findSentinelBlock(raw)
150
+ return block?.version ?? null
151
+ }
152
+
153
+ /**
154
+ * Append-or-replace a sentinel-wrapped block in a merge-capable file.
155
+ * Creates the file (and parent dirs) when missing.
156
+ *
157
+ * Returns:
158
+ * "created" — file did not exist before
159
+ * "updated" — block existed but body/version differed
160
+ * "unchanged" — block existed and content matched exactly
161
+ */
162
+ function writeSentinelBlock(
163
+ filePath: string,
164
+ body: string,
165
+ version: string,
166
+ opts: { check: boolean; upgrade: boolean },
167
+ ): WriteResult {
168
+ const block = makeSentinelBlock(body, version)
169
+ const exists = fs.existsSync(filePath)
170
+
171
+ if (!exists) {
172
+ if (opts.check) return { status: "created", path: filePath }
173
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
174
+ fs.writeFileSync(filePath, block, "utf8")
175
+ return { status: "created", path: filePath }
176
+ }
177
+
178
+ const current = fs.readFileSync(filePath, "utf8")
179
+ const found = findSentinelBlock(current)
180
+
181
+ let next: string
182
+ if (found) {
183
+ const existingBlock = current.slice(found.start, found.end)
184
+ if (existingBlock.trimEnd() === block.trimEnd()) {
185
+ return { status: "unchanged", path: filePath }
186
+ }
187
+ if (opts.upgrade && found.version === version) {
188
+ // Same version but body drifted (manual edit?). Don't clobber unless
189
+ // explicitly asked via --all/non-upgrade install path.
190
+ return {
191
+ status: "unchanged",
192
+ path: filePath,
193
+ note: "version matches; pass --all to force overwrite",
194
+ }
195
+ }
196
+ next =
197
+ current.slice(0, found.start) +
198
+ block.trimEnd() +
199
+ "\n" +
200
+ current.slice(found.end).replace(/^\r?\n+/, "")
201
+ } else {
202
+ // Append at end with a separating blank line.
203
+ const sep = current.endsWith("\n") ? "\n" : "\n\n"
204
+ next = current + sep + block
205
+ }
206
+
207
+ if (opts.check) return { status: "updated", path: filePath }
208
+ fs.writeFileSync(filePath, next, "utf8")
209
+ return { status: "updated", path: filePath }
210
+ }
211
+
212
+ /**
213
+ * Write a standalone file (Claude / Cursor targets) without sentinel.
214
+ * Compares bytes to detect unchanged. Always creates parent dirs.
215
+ */
216
+ function writeStandalone(
217
+ filePath: string,
218
+ contents: string,
219
+ opts: { check: boolean; upgrade: boolean; installedVersion: string | null; nextVersion: string },
220
+ ): WriteResult {
221
+ const exists = fs.existsSync(filePath)
222
+ if (exists) {
223
+ const current = fs.readFileSync(filePath, "utf8")
224
+ if (current === contents) return { status: "unchanged", path: filePath }
225
+ if (opts.upgrade && opts.installedVersion === opts.nextVersion) {
226
+ return {
227
+ status: "unchanged",
228
+ path: filePath,
229
+ note: "version matches; pass --all to force overwrite",
230
+ }
231
+ }
232
+ }
233
+ if (opts.check) {
234
+ return { status: exists ? "updated" : "created", path: filePath }
235
+ }
236
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
237
+ fs.writeFileSync(filePath, contents, "utf8")
238
+ return { status: exists ? "updated" : "created", path: filePath }
239
+ }
240
+
241
+ // ── Target detection ─────────────────────────────────────────────────────
242
+
243
+ interface DetectFlags {
244
+ claude: boolean
245
+ cursor: boolean
246
+ windsurf: boolean
247
+ agents: boolean
248
+ all: boolean
249
+ noAgents: boolean
250
+ }
251
+
252
+ function detectTargets(cwd: string, flags: DetectFlags): Target[] {
253
+ if (flags.all) return ["claude", "cursor", "windsurf", "agents"]
254
+
255
+ const explicit: Target[] = []
256
+ if (flags.claude) explicit.push("claude")
257
+ if (flags.cursor) explicit.push("cursor")
258
+ if (flags.windsurf) explicit.push("windsurf")
259
+ if (flags.agents) explicit.push("agents")
260
+ if (explicit.length > 0) return explicit
261
+
262
+ // Autodetect.
263
+ const detected: Target[] = []
264
+ if (
265
+ fs.existsSync(path.join(cwd, ".claude")) ||
266
+ fs.existsSync(path.join(cwd, "CLAUDE.md"))
267
+ ) {
268
+ detected.push("claude")
269
+ }
270
+ if (fs.existsSync(path.join(cwd, ".cursor"))) detected.push("cursor")
271
+ if (fs.existsSync(path.join(cwd, ".windsurfrules"))) detected.push("windsurf")
272
+
273
+ if (detected.length === 0 && !flags.noAgents) {
274
+ detected.push("agents")
275
+ } else if (!flags.noAgents && !detected.includes("agents")) {
276
+ // Also drop an AGENTS.md alongside detected tools so non-Claude/Cursor
277
+ // agents (Aider, Cline, Codex, …) still pick up the skill.
278
+ detected.push("agents")
279
+ }
280
+ return detected
281
+ }
282
+
283
+ // ── Per-target installers ────────────────────────────────────────────────
284
+
285
+ function installClaude(
286
+ cwd: string,
287
+ skill: BundledSkill,
288
+ opts: { check: boolean; upgrade: boolean },
289
+ ): WriteResult {
290
+ // Claude SKILL spec requires the file as-is with frontmatter.
291
+ const target = path.join(cwd, ".claude", "skills", "sentroy", "SKILL.md")
292
+ const installed = parseSkillVersion(
293
+ fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "",
294
+ )
295
+ return writeStandalone(target, skill.raw, {
296
+ check: opts.check,
297
+ upgrade: opts.upgrade,
298
+ installedVersion: installed,
299
+ nextVersion: skill.version,
300
+ })
301
+ }
302
+
303
+ function installCursor(
304
+ cwd: string,
305
+ skill: BundledSkill,
306
+ opts: { check: boolean; upgrade: boolean },
307
+ ): WriteResult {
308
+ const target = path.join(cwd, ".cursor", "rules", "sentroy.mdc")
309
+ const body = stripFrontmatter(skill.raw)
310
+ const mdc =
311
+ `---\n` +
312
+ `description: Sentroy SDK reference\n` +
313
+ `globs: "**/*"\n` +
314
+ `alwaysApply: false\n` +
315
+ `version: ${skill.version}\n` +
316
+ `---\n\n` +
317
+ body
318
+ // Parse installed version from the .mdc frontmatter we wrote previously.
319
+ let installed: string | null = null
320
+ if (fs.existsSync(target)) {
321
+ const fm = extractFrontmatter(fs.readFileSync(target, "utf8"))
322
+ if (fm) {
323
+ const m = fm.match(/^\s*version\s*:\s*['"]?([^'"\n]+)['"]?\s*$/m)
324
+ if (m) installed = m[1]!.trim()
325
+ }
326
+ }
327
+ return writeStandalone(target, mdc, {
328
+ check: opts.check,
329
+ upgrade: opts.upgrade,
330
+ installedVersion: installed,
331
+ nextVersion: skill.version,
332
+ })
333
+ }
334
+
335
+ function installWindsurf(
336
+ cwd: string,
337
+ skill: BundledSkill,
338
+ opts: { check: boolean; upgrade: boolean },
339
+ ): WriteResult {
340
+ const target = path.join(cwd, ".windsurfrules")
341
+ const body = stripFrontmatter(skill.raw)
342
+ return writeSentinelBlock(target, body, skill.version, opts)
343
+ }
344
+
345
+ function installAgents(
346
+ cwd: string,
347
+ skill: BundledSkill,
348
+ opts: { check: boolean; upgrade: boolean },
349
+ ): WriteResult {
350
+ const target = path.join(cwd, "AGENTS.md")
351
+ // Keep frontmatter for AGENTS.md too — many tools (Codex, Cline) read it
352
+ // verbatim, and the YAML block is harmless markdown to those that don't.
353
+ const body = skill.raw
354
+ return writeSentinelBlock(target, body, skill.version, opts)
355
+ }
356
+
357
+ // ── Main handler ─────────────────────────────────────────────────────────
358
+
359
+ async function cmdInstall(args: string[]): Promise<void> {
360
+ const { flags } = parseFlags(args)
361
+
362
+ const detectFlags: DetectFlags = {
363
+ claude: flags.claude === true,
364
+ cursor: flags.cursor === true,
365
+ windsurf: flags.windsurf === true,
366
+ agents: flags.agents === true,
367
+ all: flags.all === true,
368
+ noAgents: flags["no-agents"] === true,
369
+ }
370
+ const check = flags.check === true
371
+ const upgrade = flags.upgrade === true
372
+ const sourceFlag =
373
+ typeof flags.source === "string" ? (flags.source as string) : undefined
374
+
375
+ const skill = readBundledSkill(sourceFlag)
376
+ const cwd = process.cwd()
377
+ const targets = detectTargets(cwd, detectFlags)
378
+
379
+ if (targets.length === 0) {
380
+ fail(
381
+ "no install targets resolved. Pass --claude/--cursor/--windsurf/--agents or --all.",
382
+ )
383
+ }
384
+
385
+ info(
386
+ `Sentroy skill v${c.bold(skill.version)} ${check ? c.dim("(dry-run)") : ""}`.trim(),
387
+ )
388
+ info(
389
+ `source: ${c.dim(sourceFlag ?? "bundled (" + path.join(__dirname, "..", "..", "skill", "SKILL.md") + ")")}`,
390
+ )
391
+ info(`targets: ${c.cyan(targets.join(", "))}`)
392
+ process.stdout.write("\n")
393
+
394
+ const results: WriteResult[] = []
395
+ for (const t of targets) {
396
+ try {
397
+ let r: WriteResult
398
+ if (t === "claude") r = installClaude(cwd, skill, { check, upgrade })
399
+ else if (t === "cursor") r = installCursor(cwd, skill, { check, upgrade })
400
+ else if (t === "windsurf") r = installWindsurf(cwd, skill, { check, upgrade })
401
+ else r = installAgents(cwd, skill, { check, upgrade })
402
+ results.push(r)
403
+ printResult(t, r, check)
404
+ } catch (err) {
405
+ const msg = err instanceof Error ? err.message : String(err)
406
+ warn(`${t}: ${msg}`)
407
+ results.push({ status: "skipped", path: "(error)", note: msg })
408
+ }
409
+ }
410
+
411
+ process.stdout.write("\n")
412
+ const created = results.filter((r) => r.status === "created").length
413
+ const updated = results.filter((r) => r.status === "updated").length
414
+ const unchanged = results.filter((r) => r.status === "unchanged").length
415
+ const skipped = results.filter((r) => r.status === "skipped").length
416
+ const verb = check ? "would" : ""
417
+ ok(
418
+ `${verb ? verb + " " : ""}created: ${c.bold(String(created))} ` +
419
+ `${verb ? verb + " " : ""}updated: ${c.bold(String(updated))} ` +
420
+ `unchanged: ${c.bold(String(unchanged))} ` +
421
+ `skipped: ${c.bold(String(skipped))}`,
422
+ )
423
+ if (check) info("dry-run — no files written. Re-run without --check to apply.")
424
+ }
425
+
426
+ function printResult(target: Target, r: WriteResult, check: boolean): void {
427
+ const verb = check ? "would " : ""
428
+ const rel = path.relative(process.cwd(), r.path) || r.path
429
+ if (r.status === "created") ok(`${target}: ${verb}create ${c.dim(rel)}`)
430
+ else if (r.status === "updated") ok(`${target}: ${verb}update ${c.dim(rel)}`)
431
+ else if (r.status === "unchanged")
432
+ info(`${target}: unchanged ${c.dim(rel)}${r.note ? c.dim(" — " + r.note) : ""}`)
433
+ else warn(`${target}: skipped ${r.note ? c.dim(r.note) : ""}`)
434
+ }
435
+
436
+ // ── Export ───────────────────────────────────────────────────────────────
437
+
438
+ export const AI_HANDLERS = {
439
+ install: cmdInstall,
440
+ }