@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.
- package/README.md +53 -5
- package/dist/auth/client.d.ts.map +1 -1
- package/dist/auth/client.js +8 -1
- package/dist/auth/client.js.map +1 -1
- package/dist/auth/react/index.js +1 -1
- package/dist/auth/react/index.js.map +1 -1
- package/dist/auth/react-native/index.d.ts +93 -0
- package/dist/auth/react-native/index.d.ts.map +1 -0
- package/dist/auth/react-native/index.js +106 -0
- package/dist/auth/react-native/index.js.map +1 -0
- package/dist/cli/ai.d.ts +35 -0
- package/dist/cli/ai.d.ts.map +1 -0
- package/dist/cli/ai.js +399 -0
- package/dist/cli/ai.js.map +1 -0
- package/dist/cli/args.d.ts +62 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +199 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/env.d.ts.map +1 -1
- package/dist/cli/env.js +8 -2
- package/dist/cli/env.js.map +1 -1
- package/dist/cli/format.d.ts +37 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/format.js +129 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +128 -25
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mail.d.ts +25 -0
- package/dist/cli/mail.d.ts.map +1 -0
- package/dist/cli/mail.js +253 -0
- package/dist/cli/mail.js.map +1 -0
- package/dist/cli/storage.d.ts +28 -0
- package/dist/cli/storage.d.ts.map +1 -0
- package/dist/cli/storage.js +189 -0
- package/dist/cli/storage.js.map +1 -0
- package/package.json +9 -2
- package/skill/SKILL.md +577 -0
- package/src/auth/client.ts +8 -1
- package/src/auth/react/index.tsx +1 -1
- package/src/auth/react-native/index.ts +157 -0
- package/src/cli/ai.ts +440 -0
- package/src/cli/args.ts +225 -0
- package/src/cli/env.ts +10 -2
- package/src/cli/format.ts +147 -0
- package/src/cli/index.ts +147 -25
- package/src/cli/mail.ts +363 -0
- 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
|
+
}
|