@extuitive/skill 0.1.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/LICENSE +21 -0
- package/README.md +514 -0
- package/bin/cli.mjs +932 -0
- package/package.json +40 -0
- package/skills/extuitive/SKILL.md +63 -0
- package/skills/extuitive/references/connect.md +75 -0
- package/skills/extuitive/references/init.md +83 -0
- package/skills/extuitive/references/select.md +89 -0
- package/skills/extuitive/references/tools.md +252 -0
- package/skills/extuitive/references/upload-status.md +102 -0
- package/skills/extuitive/references/upload.md +160 -0
- package/skills/extuitive/scripts/upload.mjs +340 -0
- package/src/constants.mjs +63 -0
- package/src/doctor.mjs +513 -0
- package/src/exec.mjs +139 -0
- package/src/hosts.mjs +350 -0
- package/src/install.mjs +541 -0
- package/src/mcp-setup.mjs +476 -0
- package/src/zip.mjs +246 -0
package/src/hosts.mjs
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each host keeps its skills and its MCP configuration.
|
|
3
|
+
*
|
|
4
|
+
* The hosts disagree about almost everything, so the differences are described as data here
|
|
5
|
+
* and every other module reads them from this table rather than branching on a host id of
|
|
6
|
+
* its own. Two fields carry most of that weight, because they decide which code path a host
|
|
7
|
+
* takes at all rather than merely which string it prints:
|
|
8
|
+
*
|
|
9
|
+
* - `skillDelivery` is `copy` for a host that scans a directory, and `bundle` for one that
|
|
10
|
+
* takes an upload. Claude Desktop is the second kind, which is why a ZIP writer exists.
|
|
11
|
+
* - `mcpSetup` is `cli` for a host we can drive with a command, and `connector-ui` for one
|
|
12
|
+
* where the only supported route is a panel the person clicks through themselves.
|
|
13
|
+
*
|
|
14
|
+
* Notable asymmetries, all load-bearing:
|
|
15
|
+
*
|
|
16
|
+
* - Claude Code reads personal skills from `~/.claude/skills`. Codex scans two personal
|
|
17
|
+
* locations, `$CODEX_HOME/skills` and `~/.agents/skills`, and loads from both. Codex's
|
|
18
|
+
* own source calls the first one deprecated and the second current — but its bundled
|
|
19
|
+
* `$skill-installer` still writes to `$CODEX_HOME/skills`, and so does every agent that
|
|
20
|
+
* installs a skill from a URL. We install there so an Extuitive install lands where a
|
|
21
|
+
* Codex user's other skills already are. Earlier versions of this installer used
|
|
22
|
+
* `~/.agents/skills`; that is listed in `previousUserSkillsDirs` so install can migrate a
|
|
23
|
+
* copy out of it and doctor can explain a duplicate. If Codex ever stops scanning
|
|
24
|
+
* `$CODEX_HOME/skills`, swapping the two entries here is the whole change.
|
|
25
|
+
* - Codex picks up new and changed skills between turns (since 0.97). Claude Code does too,
|
|
26
|
+
* and Claude Desktop reads them when a chat starts. None needs an application restart for
|
|
27
|
+
* the skill itself, which is why `loadsSkillsAtStartup` is false everywhere — kept as data
|
|
28
|
+
* rather than deleted so a host that regresses is a one-line fix.
|
|
29
|
+
* - Every host connects MCP servers when a session starts, which is tracked separately from
|
|
30
|
+
* skills because it is what actually decides whether install has to end by telling
|
|
31
|
+
* someone to open a new session: the skill is live, the server it talks to is not.
|
|
32
|
+
* - The host CLI is resolved, not assumed. See `resolveCli` for why a `codex` on PATH is not
|
|
33
|
+
* evidence that `codex` runs.
|
|
34
|
+
*/
|
|
35
|
+
import { homedir, platform } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { existsSync } from "node:fs";
|
|
38
|
+
|
|
39
|
+
import { resolveCli } from "./exec.mjs";
|
|
40
|
+
|
|
41
|
+
/** Everything this package keeps for itself: backups, and bundles built for upload. */
|
|
42
|
+
export function stateRoot() {
|
|
43
|
+
return join(homedir(), ".extuitive-skill");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `CODEX_HOME` relocates everything Codex owns: its config file and, for us, its skills
|
|
48
|
+
* directory. Read at call time rather than at import so tests can point a whole run at a
|
|
49
|
+
* scratch directory.
|
|
50
|
+
*/
|
|
51
|
+
function codexHome() {
|
|
52
|
+
const configured = process.env.CODEX_HOME;
|
|
53
|
+
if (typeof configured === "string" && configured.trim() !== "") {
|
|
54
|
+
return configured;
|
|
55
|
+
}
|
|
56
|
+
return join(homedir(), ".codex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function claudeHome() {
|
|
60
|
+
return join(homedir(), ".claude");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where the Claude Desktop app keeps its own state, which is not `~/.claude`.
|
|
65
|
+
*
|
|
66
|
+
* Only used to tell whether the app is on this machine. Nothing here is ever written: the
|
|
67
|
+
* config file beside it takes stdio servers only, and an entry with a `url` makes Claude
|
|
68
|
+
* Desktop delete the whole `mcpServers` block on next launch.
|
|
69
|
+
*/
|
|
70
|
+
function claudeDesktopHome() {
|
|
71
|
+
if (platform() === "darwin") {
|
|
72
|
+
return join(homedir(), "Library", "Application Support", "Claude");
|
|
73
|
+
}
|
|
74
|
+
if (platform() === "win32") {
|
|
75
|
+
const appData = process.env.APPDATA;
|
|
76
|
+
return typeof appData === "string" && appData.trim() !== ""
|
|
77
|
+
? join(appData, "Claude")
|
|
78
|
+
: join(homedir(), "AppData", "Roaming", "Claude");
|
|
79
|
+
}
|
|
80
|
+
return join(homedir(), ".config", "Claude");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Installed application bundles, for the case a state directory cannot cover: an app that
|
|
85
|
+
* has been installed but never launched has written nothing anywhere yet.
|
|
86
|
+
*
|
|
87
|
+
* macOS only, because it is the one platform where an application's presence is a stable
|
|
88
|
+
* path rather than a registry key or a package manager's opinion. Elsewhere the state
|
|
89
|
+
* directory is the whole answer, which costs nothing but a first launch.
|
|
90
|
+
*/
|
|
91
|
+
function appBundles(names) {
|
|
92
|
+
if (platform() !== "darwin") {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
return names.map((name) => `/Applications/${name}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const HOST_IDS = ["claude", "codex", "claude-desktop"];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where a host's CLI might be, most specific first.
|
|
102
|
+
*
|
|
103
|
+
* An explicit `*_CLI_PATH` beats PATH, and PATH beats the desktop app bundles, which are
|
|
104
|
+
* checked last because they only exist on macOS and only when the app is installed. The
|
|
105
|
+
* bundle paths matter for exactly one case: a person who uses the Codex app and has never
|
|
106
|
+
* installed the CLI, or has a broken one on PATH, still has a working binary.
|
|
107
|
+
*/
|
|
108
|
+
function cliCandidates(id) {
|
|
109
|
+
if (id === "claude") {
|
|
110
|
+
return [process.env.CLAUDE_CLI_PATH, "claude"];
|
|
111
|
+
}
|
|
112
|
+
if (id === "codex") {
|
|
113
|
+
const candidates = [process.env.CODEX_CLI_PATH, "codex"];
|
|
114
|
+
if (platform() === "darwin") {
|
|
115
|
+
for (const root of ["/Applications", join(homedir(), "Applications")]) {
|
|
116
|
+
candidates.push(join(root, "Codex.app", "Contents", "Resources", "codex"));
|
|
117
|
+
candidates.push(join(root, "ChatGPT.app", "Contents", "Resources", "codex"));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return candidates;
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolved once per process per host. Probing runs `--version`, which costs real time, and
|
|
127
|
+
* `getHost` is called from every module; without a cache a single install would spawn the
|
|
128
|
+
* CLI a dozen times to learn the same thing.
|
|
129
|
+
*/
|
|
130
|
+
const cliCache = new Map();
|
|
131
|
+
|
|
132
|
+
const NO_CLI = { state: "none", path: null, detail: "This host has no CLI.", tried: [] };
|
|
133
|
+
|
|
134
|
+
function resolvedCli(id) {
|
|
135
|
+
if (cliCache.has(id) === false) {
|
|
136
|
+
const candidates = cliCandidates(id);
|
|
137
|
+
cliCache.set(id, candidates.length === 0 ? NO_CLI : resolveCli(candidates));
|
|
138
|
+
}
|
|
139
|
+
return cliCache.get(id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @returns {{
|
|
144
|
+
* id: string,
|
|
145
|
+
* label: string,
|
|
146
|
+
* surfaces: string,
|
|
147
|
+
* cli: string | null,
|
|
148
|
+
* cliCommand: string | null,
|
|
149
|
+
* cliResolution: { state: "available" | "broken" | "missing" | "none", path: string | null, detail: string },
|
|
150
|
+
* skillDelivery: "copy" | "bundle",
|
|
151
|
+
* mcpSetup: "cli" | "connector-ui",
|
|
152
|
+
* supportsScope: boolean,
|
|
153
|
+
* userSkillsDir: string,
|
|
154
|
+
* previousUserSkillsDirs: string[],
|
|
155
|
+
* projectSkillsDir: (cwd: string) => string,
|
|
156
|
+
* configPath: string | null,
|
|
157
|
+
* markerPaths: string[],
|
|
158
|
+
* loadsSkillsAtStartup: boolean,
|
|
159
|
+
* loadsMcpAtStartup: boolean,
|
|
160
|
+
* sessionNoun: string,
|
|
161
|
+
* invocationPrefix: string,
|
|
162
|
+
* invocationNote: string | null,
|
|
163
|
+
* }}
|
|
164
|
+
*/
|
|
165
|
+
export function getHost(id) {
|
|
166
|
+
if (id === "claude") {
|
|
167
|
+
const cli = resolvedCli("claude");
|
|
168
|
+
return {
|
|
169
|
+
id: "claude",
|
|
170
|
+
label: "Claude Code",
|
|
171
|
+
// The Code tab of the Claude Desktop app reads this same directory, so installing
|
|
172
|
+
// here is how that tab gets the skill. Its Chat and Cowork tabs do not.
|
|
173
|
+
surfaces: "the claude CLI and the Code tab of the Claude Desktop app",
|
|
174
|
+
cli: "claude",
|
|
175
|
+
// What to spawn and what to print. The bare name when PATH has a working one; the
|
|
176
|
+
// full path when only a fallback location did.
|
|
177
|
+
cliCommand: cli.state === "available" ? cli.path : "claude",
|
|
178
|
+
cliResolution: cli,
|
|
179
|
+
skillDelivery: "copy",
|
|
180
|
+
mcpSetup: "cli",
|
|
181
|
+
supportsScope: true,
|
|
182
|
+
userSkillsDir: join(claudeHome(), "skills"),
|
|
183
|
+
previousUserSkillsDirs: [],
|
|
184
|
+
projectSkillsDir: (cwd) => join(cwd, ".claude", "skills"),
|
|
185
|
+
configPath: join(homedir(), ".claude.json"),
|
|
186
|
+
markerPaths: [claudeHome()],
|
|
187
|
+
loadsSkillsAtStartup: false,
|
|
188
|
+
// The server is not live. Claude Code connects its MCP servers when a session starts,
|
|
189
|
+
// and `/mcp` lists only what the session connected to — so the session that ran the
|
|
190
|
+
// install cannot sign in to what the install just registered.
|
|
191
|
+
loadsMcpAtStartup: true,
|
|
192
|
+
sessionNoun: "session",
|
|
193
|
+
invocationPrefix: "/",
|
|
194
|
+
invocationNote: null,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (id === "codex") {
|
|
199
|
+
const home = codexHome();
|
|
200
|
+
const cli = resolvedCli("codex");
|
|
201
|
+
return {
|
|
202
|
+
id: "codex",
|
|
203
|
+
// Not "Codex CLI". The Codex desktop app, the CLI and the IDE extension are one host
|
|
204
|
+
// wearing three faces: they share `~/.codex/config.toml` for MCP and the same skills
|
|
205
|
+
// directories, so a single install serves all three and a second host entry would
|
|
206
|
+
// only copy the same files over themselves.
|
|
207
|
+
label: "Codex",
|
|
208
|
+
surfaces: "the Codex CLI, the Codex desktop app, and the IDE extension",
|
|
209
|
+
cli: "codex",
|
|
210
|
+
cliCommand: cli.state === "available" ? cli.path : "codex",
|
|
211
|
+
cliResolution: cli,
|
|
212
|
+
skillDelivery: "copy",
|
|
213
|
+
mcpSetup: "cli",
|
|
214
|
+
supportsScope: true,
|
|
215
|
+
userSkillsDir: join(home, "skills"),
|
|
216
|
+
// Where this installer used to put the skill. Deliberately not under CODEX_HOME: the
|
|
217
|
+
// `.agents` convention is shared across tools and never moved with it.
|
|
218
|
+
previousUserSkillsDirs: [join(homedir(), ".agents", "skills")],
|
|
219
|
+
projectSkillsDir: (cwd) => join(cwd, ".agents", "skills"),
|
|
220
|
+
configPath: join(home, "config.toml"),
|
|
221
|
+
markerPaths: [home, ...appBundles(["Codex.app", "ChatGPT.app"])],
|
|
222
|
+
loadsSkillsAtStartup: false,
|
|
223
|
+
loadsMcpAtStartup: true,
|
|
224
|
+
sessionNoun: "session",
|
|
225
|
+
invocationPrefix: "$",
|
|
226
|
+
invocationNote: null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (id === "claude-desktop") {
|
|
231
|
+
return {
|
|
232
|
+
id: "claude-desktop",
|
|
233
|
+
label: "Claude Desktop",
|
|
234
|
+
// Scoped to the tabs this entry actually serves. The Code tab is Claude Code and is
|
|
235
|
+
// set up by the `claude` host; saying so here is the difference between installing
|
|
236
|
+
// once and installing twice.
|
|
237
|
+
surfaces: "the Chat and Cowork tabs of the Claude Desktop app",
|
|
238
|
+
// No CLI at all, which is why every other field about driving this host describes a
|
|
239
|
+
// panel rather than a command.
|
|
240
|
+
cli: null,
|
|
241
|
+
cliCommand: null,
|
|
242
|
+
cliResolution: NO_CLI,
|
|
243
|
+
// Chat-tab skills are account-bound and run in Anthropic's code execution container.
|
|
244
|
+
// There is no directory on this machine to copy into: the Customize > Skills panel
|
|
245
|
+
// takes a `.zip` and syncs it to the account, which is also why the skill then works
|
|
246
|
+
// on claude.ai and on other devices. Cowork reads a cache pulled back down from that
|
|
247
|
+
// same account, so the upload is what reaches both tabs.
|
|
248
|
+
skillDelivery: "bundle",
|
|
249
|
+
// `claude_desktop_config.json` validates stdio servers only. An entry carrying a `url`
|
|
250
|
+
// is not merely ignored — Claude Desktop rewrites the file on next launch with the
|
|
251
|
+
// whole `mcpServers` block removed, taking any hand-written servers with it. A remote
|
|
252
|
+
// HTTPS endpoint belongs in Settings > Connectors, which also handles the OAuth we
|
|
253
|
+
// need and stores the token where the app expects it.
|
|
254
|
+
mcpSetup: "connector-ui",
|
|
255
|
+
// A skill uploaded to an account is not installed per project, so `--scope project`
|
|
256
|
+
// has nothing to mean here.
|
|
257
|
+
supportsScope: false,
|
|
258
|
+
userSkillsDir: join(stateRoot(), "bundles"),
|
|
259
|
+
previousUserSkillsDirs: [],
|
|
260
|
+
projectSkillsDir: () => join(stateRoot(), "bundles"),
|
|
261
|
+
configPath: null,
|
|
262
|
+
markerPaths: [claudeDesktopHome(), ...appBundles(["Claude.app"])],
|
|
263
|
+
// No application restart is in the way: an upload is live for new chats as soon as it
|
|
264
|
+
// finishes, and a connector likewise. The open chat is what has the stale view, since
|
|
265
|
+
// both are read when a chat starts.
|
|
266
|
+
loadsSkillsAtStartup: false,
|
|
267
|
+
loadsMcpAtStartup: true,
|
|
268
|
+
sessionNoun: "chat",
|
|
269
|
+
invocationPrefix: "/",
|
|
270
|
+
// Worth stating because the other two hosts train the opposite habit. Chat has no
|
|
271
|
+
// `$extuitive`; it selects a skill by matching the request against its description.
|
|
272
|
+
invocationNote:
|
|
273
|
+
"Claude picks the skill from its description, so ask for the underlying thing — " +
|
|
274
|
+
'"upload these ads to Extuitive" — rather than typing a command.',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
throw new Error(`Unknown host: ${id}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function allHosts() {
|
|
282
|
+
return HOST_IDS.map(getHost);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* What we can tell about a host without asking the user.
|
|
287
|
+
*
|
|
288
|
+
* `cliAvailable` and `configPresent` are reported separately because they fail differently:
|
|
289
|
+
* no working CLI means we cannot register the MCP server for them and have to print the
|
|
290
|
+
* step, while no state directory usually means the host was never run. Either alone is
|
|
291
|
+
* still enough to install skills, since that is only file copying.
|
|
292
|
+
*
|
|
293
|
+
* A host with no CLI is `cliAvailable: false` permanently rather than by accident, and the
|
|
294
|
+
* modules that would otherwise print "put it on your PATH" read `mcpSetup` to tell the two
|
|
295
|
+
* cases apart. A host whose CLI is found but does not run is `cliResolution.state ===
|
|
296
|
+
* "broken"`, which still counts as "this host is here": the person has it, we just cannot
|
|
297
|
+
* drive it.
|
|
298
|
+
*/
|
|
299
|
+
export function detectHost(id) {
|
|
300
|
+
const host = getHost(id);
|
|
301
|
+
const cliAvailable = host.cliResolution.state === "available";
|
|
302
|
+
const configPresent = host.markerPaths.some((path) => existsSync(path));
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
host,
|
|
306
|
+
cliAvailable,
|
|
307
|
+
configPresent,
|
|
308
|
+
installed: cliAvailable || configPresent || host.cliResolution.state === "broken",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function detectHosts() {
|
|
313
|
+
return HOST_IDS.map(detectHost);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The directory skills go into for a scope, with `--dir` overriding both. */
|
|
317
|
+
export function resolveSkillsRoot(host, { scope = "user", dir = null, cwd = process.cwd() } = {}) {
|
|
318
|
+
if (dir !== null) {
|
|
319
|
+
return dir;
|
|
320
|
+
}
|
|
321
|
+
if (host.supportsScope === false) {
|
|
322
|
+
return host.userSkillsDir;
|
|
323
|
+
}
|
|
324
|
+
return scope === "project" ? host.projectSkillsDir(cwd) : host.userSkillsDir;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Roots this installer wrote to in earlier versions, for the scope in question.
|
|
329
|
+
*
|
|
330
|
+
* Only a personal install to the default location has a history to migrate. A `--dir` or a
|
|
331
|
+
* project install names exactly one directory and a copy anywhere else is not ours to move.
|
|
332
|
+
*/
|
|
333
|
+
export function previousSkillsRoots(host, { scope = "user", dir = null } = {}) {
|
|
334
|
+
if (dir !== null || scope !== "user") {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
return host.previousUserSkillsDirs.filter((root) => root !== host.userSkillsDir);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** `~/...` for display, without pretending paths outside home are under it. */
|
|
341
|
+
export function displayPath(path) {
|
|
342
|
+
const home = homedir();
|
|
343
|
+
if (path === home) {
|
|
344
|
+
return "~";
|
|
345
|
+
}
|
|
346
|
+
if (path.startsWith(`${home}/`) === true) {
|
|
347
|
+
return `~${path.slice(home.length)}`;
|
|
348
|
+
}
|
|
349
|
+
return path;
|
|
350
|
+
}
|