xcode-skills 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 darsha80
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # xcode-skills
2
+
3
+ Manage Agent Skills for Xcode's Coding Assistant integrations.
4
+
5
+ Xcode reads skills from fixed Codex and Claude locations. Skills installed for other tools, global agent environments, or project-local folders are not automatically available to Xcode. `xcode-skills` installs and manages existing Agent Skills for Xcode only.
6
+
7
+ ## Scope
8
+
9
+ `xcode-skills` manages:
10
+
11
+ - Codex: `~/Library/Developer/Xcode/CodingAssistant/codex`
12
+ - Claude: `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig`
13
+
14
+ It does not manage global Codex skills, Claude Code skills, project-local skills, or arbitrary skill folders.
15
+
16
+ Xcode must activate an integration first. This tool will not create missing integration roots, but it can create skill-management subdirectories inside an activated root.
17
+
18
+ ## Install
19
+
20
+ From this repo:
21
+
22
+ ```sh
23
+ npm install
24
+ npm run build
25
+ npm link
26
+ ```
27
+
28
+ After npm publication, install with:
29
+
30
+ ```sh
31
+ npm install -g xcode-skills
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ ```sh
37
+ xcode-skills install <skill-spec>
38
+ xcode-skills uninstall <skill-name>
39
+ xcode-skills enable <skill-name>
40
+ xcode-skills disable <skill-name>
41
+ xcode-skills list
42
+ xcode-skills manage
43
+ ```
44
+
45
+ Write commands accept:
46
+
47
+ ```sh
48
+ --target codex|claude|both
49
+ --yes
50
+ --dry-run
51
+ --verbose
52
+ ```
53
+
54
+ If `--target` is omitted for a write command, `xcode-skills` prompts for Codex, Claude, or both.
55
+
56
+ `list` is read-only, does not prompt by default, and supports:
57
+
58
+ ```sh
59
+ --target codex|claude|both
60
+ --json
61
+ ```
62
+
63
+ ## Examples
64
+
65
+ Install a skill for Codex:
66
+
67
+ ```sh
68
+ xcode-skills install mattpocock/skills/skills/engineering/diagnose --target codex
69
+ ```
70
+
71
+ Install for both integrations:
72
+
73
+ ```sh
74
+ xcode-skills install mattpocock/skills/skills/engineering/diagnose --target both
75
+ ```
76
+
77
+ List installed skills:
78
+
79
+ ```sh
80
+ xcode-skills list
81
+ ```
82
+
83
+ Machine-readable list output:
84
+
85
+ ```sh
86
+ xcode-skills list --json
87
+ ```
88
+
89
+ Disable and re-enable a skill:
90
+
91
+ ```sh
92
+ xcode-skills disable diagnose --target codex
93
+ xcode-skills enable diagnose --target codex
94
+ ```
95
+
96
+ Uninstall a skill:
97
+
98
+ ```sh
99
+ xcode-skills uninstall diagnose --target both
100
+ ```
101
+
102
+ Preview a write operation:
103
+
104
+ ```sh
105
+ xcode-skills uninstall diagnose --target codex --dry-run
106
+ ```
107
+
108
+ ## Manage TUI
109
+
110
+ `xcode-skills manage` opens a keyboard-only management view.
111
+
112
+ Controls:
113
+
114
+ - `Tab`: switch Codex/Claude tab
115
+ - `Up` / `Down` or `k` / `j`: move selection
116
+ - `Space` / `Enter`: toggle enabled/disabled for the selected skill
117
+ - `q`: quit
118
+
119
+ The TUI shows inactive integrations, conflicts, and suspicious folders. It does not install or uninstall skills.
120
+
121
+ ## State Model
122
+
123
+ Enabled skills live at:
124
+
125
+ ```text
126
+ <integration-root>/skills/<skill-name>/
127
+ ```
128
+
129
+ Disabled skills are preserved offline at:
130
+
131
+ ```text
132
+ <integration-root>/.xcode-skills/disabled/<skill-name>/
133
+ ```
134
+
135
+ States:
136
+
137
+ - `enabled`: active Skill Folder exists
138
+ - `disabled`: disabled Skill Folder exists
139
+ - `conflict`: active and disabled copies both exist
140
+ - `suspicious`: a discovered folder lacks `SKILL.md`
141
+ - `not-installed`: neither active nor disabled copy exists
142
+
143
+ `disable` is reversible offline. `uninstall` removes both enabled and disabled copies for the selected target integration.
144
+
145
+ ## Resolver
146
+
147
+ `xcode-skills` depends on the `skills` npm package and invokes its packaged CLI as the Skill Source Resolver. It resolves a Skill Spec once in a temporary workspace, uses copy mode, then copies the resolved Skill Folder into the selected Xcode integration locations.
148
+
149
+ The temporary workspace is deleted after the operation, including failure and dry-run cases.
150
+
151
+ ## Safety
152
+
153
+ `xcode-skills` only touches paths for the selected skill:
154
+
155
+ ```text
156
+ skills/<skill-name>/
157
+ .xcode-skills/disabled/<skill-name>/
158
+ ```
159
+
160
+ It preserves unrelated Skill Folders, Xcode files, and user files.
161
+
162
+ The tool requires confirmation before overwriting or deleting no-lock/manual folders. Use `--yes` to bypass confirmation in automation.
163
+
164
+ ## Development
165
+
166
+ ```sh
167
+ npm install
168
+ npm test
169
+ npm run typecheck
170
+ npm run build
171
+ ```
172
+
173
+ The test suite uses fake integration roots and does not touch real Xcode directories.
package/dist/cli.js ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { installSkill } from "./install.js";
5
+ import { resolveIntegrationRoots } from "./integrations.js";
6
+ import { formatListHuman, formatListJson, listSkillInstallations } from "./list.js";
7
+ import { buildManageView, formatManageView, runManageSession } from "./manage.js";
8
+ import { disableSkill, enableSkill } from "./lifecycle.js";
9
+ import { resolveSkillArtifactWithCli } from "./resolver.js";
10
+ import { selectTargetIntegrations } from "./targets.js";
11
+ import { uninstallSkill } from "./uninstall.js";
12
+ export async function runCli(argv, dependencies = {}) {
13
+ try {
14
+ const parsed = parseArgs(argv);
15
+ const integrations = await (dependencies.resolveIntegrationRoots ?? resolveIntegrationRoots)();
16
+ const verboseLines = [];
17
+ if (parsed.command === "list") {
18
+ const entries = await listSkillInstallations(integrations, { target: parsed.target });
19
+ return ok(parsed.json ? formatListJson(entries) : formatListHuman(entries));
20
+ }
21
+ if (parsed.command === "manage") {
22
+ const readKey = dependencies.readManageKey ??
23
+ (process.stdin.isTTY ? readManageKeyFromStdin : undefined);
24
+ if (readKey !== undefined) {
25
+ const interactiveTty = dependencies.readManageKey === undefined && process.stdin.isTTY;
26
+ const output = await runManageSession(integrations, {
27
+ readKey,
28
+ render: interactiveTty
29
+ ? (screen) => {
30
+ process.stdout.write(`\x1Bc${screen}\n`);
31
+ }
32
+ : undefined,
33
+ });
34
+ return ok(interactiveTty ? "" : output);
35
+ }
36
+ return ok(formatManageView(await buildManageView(integrations)));
37
+ }
38
+ const skillArg = parsed.args[0];
39
+ if (skillArg === undefined) {
40
+ return fail(`Missing required argument for ${parsed.command}`);
41
+ }
42
+ if (["install", "uninstall", "enable", "disable"].includes(parsed.command) &&
43
+ parsed.args.length !== 1) {
44
+ return fail(`${parsed.command} accepts exactly one skill argument`);
45
+ }
46
+ const selectedIntegrations = await selectTargetIntegrations({
47
+ integrations,
48
+ target: parsed.target,
49
+ promptForTarget: dependencies.promptForTarget ?? promptForTargetFromStdin,
50
+ });
51
+ if (parsed.command === "install") {
52
+ const resolver = dependencies.resolver ??
53
+ dependencies.createResolver?.((line) => verboseLines.push(line)) ??
54
+ ((skillSpec) => resolveSkillArtifactWithCli(skillSpec, {
55
+ onVerbose: parsed.verbose ? (line) => verboseLines.push(line) : undefined,
56
+ }));
57
+ const results = await installSkill({
58
+ skillSpec: skillArg,
59
+ integrations: selectedIntegrations,
60
+ resolver,
61
+ dryRun: parsed.dryRun,
62
+ yes: parsed.yes,
63
+ confirmOverwrite: parsed.yes || parsed.dryRun
64
+ ? undefined
65
+ : (path) => confirmWithDependencies(dependencies, `overwrite manual Skill Folder?\n ${path}`),
66
+ });
67
+ return ok(withVerbose(formatInstallResults(results), parsed.verbose ? verboseLines : []));
68
+ }
69
+ if (parsed.command === "enable" || parsed.command === "disable") {
70
+ const results = await Promise.all(selectedIntegrations.map((integration) => parsed.command === "enable"
71
+ ? enableSkill(integration, skillArg, { dryRun: parsed.dryRun })
72
+ : disableSkill(integration, skillArg, { dryRun: parsed.dryRun })));
73
+ return ok(formatLifecycleResults(results));
74
+ }
75
+ if (parsed.command === "uninstall") {
76
+ let results = await Promise.all(selectedIntegrations.map((integration) => uninstallSkill(integration, skillArg, {
77
+ dryRun: parsed.dryRun,
78
+ yes: parsed.yes,
79
+ })));
80
+ if (!parsed.dryRun && !parsed.yes && results.some((result) => result.status === "needs-confirmation")) {
81
+ const confirmed = await confirmWithDependencies(dependencies, [
82
+ "delete these Skill Installation paths?",
83
+ ...results.flatMap((result) => result.removedPaths.map((path) => ` ${path}`)),
84
+ ].join("\n"));
85
+ if (confirmed) {
86
+ results = await Promise.all(selectedIntegrations.map((integration) => uninstallSkill(integration, skillArg, {
87
+ yes: true,
88
+ })));
89
+ }
90
+ }
91
+ return ok(formatUninstallResults(results));
92
+ }
93
+ return fail(`Unknown command: ${parsed.command}`);
94
+ }
95
+ catch (error) {
96
+ return fail(error instanceof Error ? error.message : String(error));
97
+ }
98
+ }
99
+ function parseArgs(argv) {
100
+ const [command = "help", ...rest] = argv;
101
+ const args = [];
102
+ let target;
103
+ let yes = false;
104
+ let dryRun = false;
105
+ let verbose = false;
106
+ let json = false;
107
+ for (let index = 0; index < rest.length; index += 1) {
108
+ const token = rest[index];
109
+ if (token === "--target") {
110
+ const value = rest[index + 1];
111
+ if (value !== "codex" && value !== "claude" && value !== "both") {
112
+ throw new Error(`Invalid target: ${value ?? ""}`);
113
+ }
114
+ target = value;
115
+ index += 1;
116
+ continue;
117
+ }
118
+ if (token === "--yes" || token === "-y") {
119
+ yes = true;
120
+ continue;
121
+ }
122
+ if (token === "--dry-run") {
123
+ dryRun = true;
124
+ continue;
125
+ }
126
+ if (token === "--verbose") {
127
+ verbose = true;
128
+ continue;
129
+ }
130
+ if (token === "--json") {
131
+ json = true;
132
+ continue;
133
+ }
134
+ args.push(token);
135
+ }
136
+ return { command, args, target, yes, dryRun, verbose, json };
137
+ }
138
+ function ok(stdout) {
139
+ return { exitCode: 0, stdout, stderr: "" };
140
+ }
141
+ function fail(stderr) {
142
+ return { exitCode: 1, stdout: "", stderr };
143
+ }
144
+ function formatInstallResults(results) {
145
+ return results
146
+ .map((result) => `${displayIntegration(result.integrationId)}: ${result.status} ${result.skillIdentity}`)
147
+ .join("\n");
148
+ }
149
+ function formatLifecycleResults(results) {
150
+ return results
151
+ .map((result) => `${displayIntegration(result.integrationId)}: ${result.status} ${result.skillIdentity}`)
152
+ .join("\n");
153
+ }
154
+ function formatUninstallResults(results) {
155
+ return results
156
+ .map((result) => {
157
+ const header = `${displayIntegration(result.integrationId)}: ${result.status} ${result.skillIdentity}`;
158
+ if (!["needs-confirmation", "would-need-confirmation", "would-uninstall"].includes(result.status) ||
159
+ result.removedPaths.length === 0) {
160
+ return header;
161
+ }
162
+ return [header, ...result.removedPaths.map((path) => ` ${path}`)].join("\n");
163
+ })
164
+ .join("\n");
165
+ }
166
+ function withVerbose(stdout, verboseLines) {
167
+ if (verboseLines.length === 0) {
168
+ return stdout;
169
+ }
170
+ return [stdout, "", "Verbose", ...verboseLines].join("\n");
171
+ }
172
+ function displayIntegration(id) {
173
+ return id === "codex" ? "Codex" : "Claude";
174
+ }
175
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
176
+ const result = await runCli(process.argv.slice(2));
177
+ if (result.stdout.length > 0) {
178
+ process.stdout.write(`${result.stdout}\n`);
179
+ }
180
+ if (result.stderr.length > 0) {
181
+ process.stderr.write(`${result.stderr}\n`);
182
+ }
183
+ process.exitCode = result.exitCode;
184
+ }
185
+ async function promptForTargetFromStdin() {
186
+ const readline = createInterface({
187
+ input: process.stdin,
188
+ output: process.stdout,
189
+ });
190
+ try {
191
+ const answer = (await readline.question("Target Agent Integration (codex/claude/both): "))
192
+ .trim()
193
+ .toLowerCase();
194
+ if (answer === "codex" || answer === "claude" || answer === "both") {
195
+ return answer;
196
+ }
197
+ throw new Error(`Invalid target: ${answer}`);
198
+ }
199
+ finally {
200
+ readline.close();
201
+ }
202
+ }
203
+ async function confirmWithDependencies(dependencies, message) {
204
+ if (dependencies.confirm !== undefined) {
205
+ return dependencies.confirm(message);
206
+ }
207
+ return confirmFromStdin(message);
208
+ }
209
+ async function confirmFromStdin(message) {
210
+ const readline = createInterface({
211
+ input: process.stdin,
212
+ output: process.stdout,
213
+ });
214
+ try {
215
+ const answer = (await readline.question(`${message}\nContinue? (y/N): `))
216
+ .trim()
217
+ .toLowerCase();
218
+ return answer === "y" || answer === "yes";
219
+ }
220
+ finally {
221
+ readline.close();
222
+ }
223
+ }
224
+ async function readManageKeyFromStdin() {
225
+ const stdin = process.stdin;
226
+ if (stdin.setRawMode !== undefined) {
227
+ stdin.setRawMode(true);
228
+ }
229
+ stdin.resume();
230
+ stdin.setEncoding("utf8");
231
+ return new Promise((resolve) => {
232
+ const onData = (chunk) => {
233
+ stdin.off("data", onData);
234
+ if (stdin.setRawMode !== undefined) {
235
+ stdin.setRawMode(false);
236
+ }
237
+ stdin.pause();
238
+ resolve(chunk === "\u0003" ? "q" : chunk);
239
+ };
240
+ stdin.on("data", onData);
241
+ });
242
+ }
@@ -0,0 +1,99 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function scanIntegrationSkills(integration) {
4
+ if (!integration.activated) {
5
+ return [];
6
+ }
7
+ const activeEntries = await readSkillDirectories(integration.activeSkillsPath);
8
+ const disabledEntries = await readSkillDirectories(integration.disabledSkillsPath);
9
+ const skillIdentities = [...new Set([...activeEntries, ...disabledEntries])].sort();
10
+ return Promise.all(skillIdentities.map(async (skillIdentity) => {
11
+ const active = activeEntries.includes(skillIdentity);
12
+ const disabled = disabledEntries.includes(skillIdentity);
13
+ const activePath = active ? join(integration.activeSkillsPath, skillIdentity) : null;
14
+ const disabledPath = disabled
15
+ ? join(integration.disabledSkillsPath, skillIdentity)
16
+ : null;
17
+ const hasSkillFile = await hasRootSkillFile(activePath ?? disabledPath);
18
+ const state = !hasSkillFile
19
+ ? "suspicious"
20
+ : active && disabled
21
+ ? "conflict"
22
+ : active
23
+ ? "enabled"
24
+ : "disabled";
25
+ return {
26
+ skillIdentity,
27
+ integrationId: integration.id,
28
+ state,
29
+ provenance: await readProvenance(skillIdentity, activePath ?? disabledPath),
30
+ paths: {
31
+ active: activePath,
32
+ disabled: disabledPath,
33
+ },
34
+ };
35
+ }));
36
+ }
37
+ async function readSkillDirectories(path) {
38
+ let entries;
39
+ try {
40
+ entries = await readdir(path);
41
+ }
42
+ catch (error) {
43
+ if (isNodeError(error) && error.code === "ENOENT") {
44
+ return [];
45
+ }
46
+ throw error;
47
+ }
48
+ const directories = await Promise.all(entries.map(async (entry) => {
49
+ const entryPath = join(path, entry);
50
+ const entryStat = await stat(entryPath);
51
+ return entryStat.isDirectory() ? entry : null;
52
+ }));
53
+ return directories.filter((entry) => entry !== null).sort();
54
+ }
55
+ function isNodeError(error) {
56
+ return error instanceof Error && "code" in error;
57
+ }
58
+ async function hasRootSkillFile(skillPath) {
59
+ if (skillPath === null) {
60
+ return false;
61
+ }
62
+ try {
63
+ const skillFile = await stat(join(skillPath, "SKILL.md"));
64
+ return skillFile.isFile();
65
+ }
66
+ catch (error) {
67
+ if (isNodeError(error) && error.code === "ENOENT") {
68
+ return false;
69
+ }
70
+ throw error;
71
+ }
72
+ }
73
+ async function readProvenance(skillIdentity, skillPath) {
74
+ const unknown = {
75
+ source: null,
76
+ sourceType: null,
77
+ computedHash: null,
78
+ };
79
+ if (skillPath === null) {
80
+ return unknown;
81
+ }
82
+ let rawLockFile;
83
+ try {
84
+ rawLockFile = await readFile(join(skillPath, "skills-lock.json"), "utf8");
85
+ }
86
+ catch (error) {
87
+ if (isNodeError(error) && error.code === "ENOENT") {
88
+ return unknown;
89
+ }
90
+ throw error;
91
+ }
92
+ const parsed = JSON.parse(rawLockFile);
93
+ const entry = parsed.skills?.[skillIdentity];
94
+ return {
95
+ source: typeof entry?.source === "string" ? entry.source : null,
96
+ sourceType: typeof entry?.sourceType === "string" ? entry.sourceType : null,
97
+ computedHash: typeof entry?.computedHash === "string" ? entry.computedHash : null,
98
+ };
99
+ }
@@ -0,0 +1,43 @@
1
+ import { cp, lstat, mkdir, readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function pathExists(path) {
4
+ try {
5
+ await lstat(path);
6
+ return true;
7
+ }
8
+ catch (error) {
9
+ if (isNodeError(error) && error.code === "ENOENT") {
10
+ return false;
11
+ }
12
+ throw error;
13
+ }
14
+ }
15
+ export async function removePath(path) {
16
+ await rm(path, { recursive: true, force: true });
17
+ }
18
+ export async function copyDirectoryReplacing(source, destination) {
19
+ await assertNoSymlinks(source);
20
+ await removePath(destination);
21
+ await mkdir(destination, { recursive: true });
22
+ await rm(destination, { recursive: true, force: true });
23
+ await cp(source, destination, {
24
+ recursive: true,
25
+ preserveTimestamps: true,
26
+ errorOnExist: false,
27
+ force: true,
28
+ });
29
+ }
30
+ async function assertNoSymlinks(path) {
31
+ const entry = await lstat(path);
32
+ if (entry.isSymbolicLink()) {
33
+ throw new Error(`Resolved Skill Artifact contains symlink: ${path}`);
34
+ }
35
+ if (!entry.isDirectory()) {
36
+ return;
37
+ }
38
+ const children = await readdir(path);
39
+ await Promise.all(children.map((child) => assertNoSymlinks(join(path, child))));
40
+ }
41
+ function isNodeError(error) {
42
+ return error instanceof Error && "code" in error;
43
+ }
@@ -0,0 +1,93 @@
1
+ import { mkdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { copyDirectoryReplacing, pathExists, removePath } from "./fs-utils.js";
4
+ export async function installSkill(options) {
5
+ const artifact = await options.resolver(options.skillSpec);
6
+ try {
7
+ await validateArtifact(artifact);
8
+ const results = [];
9
+ for (const integration of options.integrations) {
10
+ if (!integration.activated) {
11
+ results.push({
12
+ integrationId: integration.id,
13
+ skillIdentity: artifact.skillIdentity,
14
+ status: "not-activated",
15
+ });
16
+ continue;
17
+ }
18
+ const activePath = join(integration.activeSkillsPath, artifact.skillIdentity);
19
+ const disabledPath = join(integration.disabledSkillsPath, artifact.skillIdentity);
20
+ const needsConfirmation = options.yes !== true &&
21
+ (await pathExists(activePath)) &&
22
+ !(await pathExists(join(activePath, "skills-lock.json")));
23
+ if (needsConfirmation && options.dryRun === true) {
24
+ results.push({
25
+ integrationId: integration.id,
26
+ skillIdentity: artifact.skillIdentity,
27
+ status: "would-need-confirmation",
28
+ });
29
+ continue;
30
+ }
31
+ if (needsConfirmation && options.confirmOverwrite !== undefined) {
32
+ const confirmed = await options.confirmOverwrite(activePath);
33
+ if (!confirmed) {
34
+ results.push({
35
+ integrationId: integration.id,
36
+ skillIdentity: artifact.skillIdentity,
37
+ status: "needs-confirmation",
38
+ });
39
+ continue;
40
+ }
41
+ }
42
+ else if (needsConfirmation) {
43
+ results.push({
44
+ integrationId: integration.id,
45
+ skillIdentity: artifact.skillIdentity,
46
+ status: "needs-confirmation",
47
+ });
48
+ continue;
49
+ }
50
+ if (options.dryRun === true) {
51
+ results.push({
52
+ integrationId: integration.id,
53
+ skillIdentity: artifact.skillIdentity,
54
+ status: "would-install",
55
+ });
56
+ continue;
57
+ }
58
+ await mkdir(integration.activeSkillsPath, { recursive: true });
59
+ await copyDirectoryReplacing(artifact.artifactPath, activePath);
60
+ await removePath(disabledPath);
61
+ results.push({
62
+ integrationId: integration.id,
63
+ skillIdentity: artifact.skillIdentity,
64
+ status: "installed",
65
+ });
66
+ }
67
+ return results;
68
+ }
69
+ finally {
70
+ await artifact.cleanup?.();
71
+ }
72
+ }
73
+ async function validateArtifact(artifact) {
74
+ const artifactStat = await stat(artifact.artifactPath);
75
+ if (!artifactStat.isDirectory()) {
76
+ throw new Error(`Resolved Skill Artifact is not a directory: ${artifact.artifactPath}`);
77
+ }
78
+ try {
79
+ const skillFile = await stat(join(artifact.artifactPath, "SKILL.md"));
80
+ if (!skillFile.isFile()) {
81
+ throw new Error(`Resolved Skill Artifact is missing root SKILL.md: ${artifact.artifactPath}`);
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (isNodeError(error) && error.code === "ENOENT") {
86
+ throw new Error(`Resolved Skill Artifact is missing root SKILL.md: ${artifact.artifactPath}`);
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+ function isNodeError(error) {
92
+ return error instanceof Error && "code" in error;
93
+ }
@@ -0,0 +1,34 @@
1
+ import { access } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ const integrationOrder = ["codex", "claude"];
6
+ function defaultRootFor(id, homeDir) {
7
+ const codingAssistantRoot = join(homeDir, "Library", "Developer", "Xcode", "CodingAssistant");
8
+ if (id === "codex") {
9
+ return join(codingAssistantRoot, "codex");
10
+ }
11
+ return join(codingAssistantRoot, "ClaudeAgentConfig");
12
+ }
13
+ async function pathExists(path) {
14
+ try {
15
+ await access(path, constants.F_OK);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export async function resolveIntegrationRoots(options = {}) {
23
+ const home = options.homeDir ?? homedir();
24
+ return Promise.all(integrationOrder.map(async (id) => {
25
+ const rootPath = options.roots?.[id] ?? defaultRootFor(id, home);
26
+ return {
27
+ id,
28
+ rootPath,
29
+ activated: await pathExists(rootPath),
30
+ activeSkillsPath: join(rootPath, "skills"),
31
+ disabledSkillsPath: join(rootPath, ".xcode-skills", "disabled"),
32
+ };
33
+ }));
34
+ }
@@ -0,0 +1,127 @@
1
+ import { mkdir, rename, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function disableSkill(integration, skillIdentity, options = {}) {
4
+ if (!integration.activated) {
5
+ return {
6
+ integrationId: integration.id,
7
+ skillIdentity,
8
+ status: "not-activated",
9
+ };
10
+ }
11
+ const activePath = join(integration.activeSkillsPath, skillIdentity);
12
+ const disabledPath = join(integration.disabledSkillsPath, skillIdentity);
13
+ const activeExists = await pathExists(activePath);
14
+ const disabledExists = await pathExists(disabledPath);
15
+ if (activeExists && disabledExists) {
16
+ return {
17
+ integrationId: integration.id,
18
+ skillIdentity,
19
+ status: "conflict",
20
+ };
21
+ }
22
+ if (!activeExists && disabledExists) {
23
+ return {
24
+ integrationId: integration.id,
25
+ skillIdentity,
26
+ status: "already-disabled",
27
+ };
28
+ }
29
+ if (!activeExists) {
30
+ return {
31
+ integrationId: integration.id,
32
+ skillIdentity,
33
+ status: "not-installed",
34
+ };
35
+ }
36
+ if (!(await pathExists(join(activePath, "SKILL.md")))) {
37
+ return {
38
+ integrationId: integration.id,
39
+ skillIdentity,
40
+ status: "suspicious",
41
+ };
42
+ }
43
+ if (options.dryRun === true) {
44
+ return {
45
+ integrationId: integration.id,
46
+ skillIdentity,
47
+ status: "would-disable",
48
+ };
49
+ }
50
+ await mkdir(integration.disabledSkillsPath, { recursive: true });
51
+ await rename(activePath, disabledPath);
52
+ return {
53
+ integrationId: integration.id,
54
+ skillIdentity,
55
+ status: "disabled",
56
+ };
57
+ }
58
+ export async function enableSkill(integration, skillIdentity, options = {}) {
59
+ if (!integration.activated) {
60
+ return {
61
+ integrationId: integration.id,
62
+ skillIdentity,
63
+ status: "not-activated",
64
+ };
65
+ }
66
+ const activePath = join(integration.activeSkillsPath, skillIdentity);
67
+ const disabledPath = join(integration.disabledSkillsPath, skillIdentity);
68
+ const activeExists = await pathExists(activePath);
69
+ const disabledExists = await pathExists(disabledPath);
70
+ if (activeExists && disabledExists) {
71
+ return {
72
+ integrationId: integration.id,
73
+ skillIdentity,
74
+ status: "conflict",
75
+ };
76
+ }
77
+ if (activeExists) {
78
+ return {
79
+ integrationId: integration.id,
80
+ skillIdentity,
81
+ status: "already-enabled",
82
+ };
83
+ }
84
+ if (!disabledExists) {
85
+ return {
86
+ integrationId: integration.id,
87
+ skillIdentity,
88
+ status: "not-installed",
89
+ };
90
+ }
91
+ if (!(await pathExists(join(disabledPath, "SKILL.md")))) {
92
+ return {
93
+ integrationId: integration.id,
94
+ skillIdentity,
95
+ status: "suspicious",
96
+ };
97
+ }
98
+ if (options.dryRun === true) {
99
+ return {
100
+ integrationId: integration.id,
101
+ skillIdentity,
102
+ status: "would-enable",
103
+ };
104
+ }
105
+ await mkdir(integration.activeSkillsPath, { recursive: true });
106
+ await rename(disabledPath, activePath);
107
+ return {
108
+ integrationId: integration.id,
109
+ skillIdentity,
110
+ status: "enabled",
111
+ };
112
+ }
113
+ async function pathExists(path) {
114
+ try {
115
+ await stat(path);
116
+ return true;
117
+ }
118
+ catch (error) {
119
+ if (isNodeError(error) && error.code === "ENOENT") {
120
+ return false;
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+ function isNodeError(error) {
126
+ return error instanceof Error && "code" in error;
127
+ }
package/dist/list.js ADDED
@@ -0,0 +1,43 @@
1
+ import { scanIntegrationSkills } from "./filesystem-state.js";
2
+ export async function listSkillInstallations(integrations, options = {}) {
3
+ const filteredIntegrations = integrations.filter((integration) => {
4
+ if (options.target === undefined || options.target === "both") {
5
+ return true;
6
+ }
7
+ return integration.id === options.target;
8
+ });
9
+ const activatedIntegrations = filteredIntegrations.filter((integration) => integration.activated);
10
+ const entries = await Promise.all(activatedIntegrations.map((integration) => scanIntegrationSkills(integration)));
11
+ return entries.flat().sort((left, right) => {
12
+ const integrationCompare = left.integrationId.localeCompare(right.integrationId);
13
+ return integrationCompare === 0
14
+ ? left.skillIdentity.localeCompare(right.skillIdentity)
15
+ : integrationCompare;
16
+ });
17
+ }
18
+ export function formatListJson(entries) {
19
+ return JSON.stringify(entries, null, 2);
20
+ }
21
+ export function formatListHuman(entries) {
22
+ if (entries.length === 0) {
23
+ return "No Xcode Skill Installations found.";
24
+ }
25
+ const grouped = new Map();
26
+ for (const entry of entries) {
27
+ grouped.set(entry.integrationId, [...(grouped.get(entry.integrationId) ?? []), entry]);
28
+ }
29
+ const sections = [...grouped.entries()].map(([integrationId, integrationEntries]) => {
30
+ const title = integrationId === "codex" ? "Codex" : "Claude";
31
+ const rows = integrationEntries.map((entry) => {
32
+ const source = entry.provenance.source === null
33
+ ? "unknown"
34
+ : `${entry.provenance.sourceType ?? "source"}:${entry.provenance.source}`;
35
+ const hash = entry.provenance.computedHash === null
36
+ ? ""
37
+ : ` ${entry.provenance.computedHash.slice(0, 8)}`;
38
+ return ` ${entry.skillIdentity} ${entry.state} ${source}${hash}`;
39
+ });
40
+ return [title, ...rows].join("\n");
41
+ });
42
+ return sections.join("\n\n");
43
+ }
package/dist/manage.js ADDED
@@ -0,0 +1,95 @@
1
+ import { scanIntegrationSkills } from "./filesystem-state.js";
2
+ import { disableSkill, enableSkill } from "./lifecycle.js";
3
+ export async function buildManageView(integrations) {
4
+ return Promise.all(integrations.map(async (integration) => ({
5
+ integrationId: integration.id,
6
+ activated: integration.activated,
7
+ skills: integration.activated ? await scanIntegrationSkills(integration) : [],
8
+ })));
9
+ }
10
+ export function formatManageView(tabs, options = {}) {
11
+ return tabs
12
+ .map((tab, tabIndex) => {
13
+ const title = tab.integrationId === "codex" ? "Codex" : "Claude";
14
+ const formattedTitle = options.activeTabIndex === tabIndex ? `[${title}]` : title;
15
+ if (!tab.activated) {
16
+ return `${formattedTitle}\n not activated in Xcode`;
17
+ }
18
+ if (tab.skills.length === 0) {
19
+ return `${formattedTitle}\n no skills`;
20
+ }
21
+ const selectedSkillIndex = options.selectedSkillIndexes?.[tabIndex] ?? -1;
22
+ return [
23
+ formattedTitle,
24
+ ...tab.skills.map((skill, skillIndex) => {
25
+ const marker = selectedSkillIndex === skillIndex ? ">" : " ";
26
+ return `${marker} ${skill.skillIdentity} ${skill.state}`;
27
+ }),
28
+ ].join("\n");
29
+ })
30
+ .join("\n\n");
31
+ }
32
+ export async function runManageSession(integrations, options) {
33
+ let activeTabIndex = 0;
34
+ const selectedSkillIndexes = integrations.map(() => 0);
35
+ let latestView = await buildManageView(integrations);
36
+ options.render?.(formatManageView(latestView, { activeTabIndex, selectedSkillIndexes }));
37
+ while (true) {
38
+ const key = await options.readKey();
39
+ if (key === "q") {
40
+ return formatManageView(latestView);
41
+ }
42
+ if (key === "Tab" || key === "\t") {
43
+ activeTabIndex = (activeTabIndex + 1) % Math.max(latestView.length, 1);
44
+ options.render?.(formatManageView(latestView, { activeTabIndex, selectedSkillIndexes }));
45
+ continue;
46
+ }
47
+ if (key === "ArrowDown" || key === "\u001B[B" || key === "j") {
48
+ const skillCount = latestView[activeTabIndex]?.skills.length ?? 0;
49
+ if (skillCount > 0) {
50
+ selectedSkillIndexes[activeTabIndex] =
51
+ ((selectedSkillIndexes[activeTabIndex] ?? 0) + 1) % skillCount;
52
+ options.render?.(formatManageView(latestView, { activeTabIndex, selectedSkillIndexes }));
53
+ }
54
+ continue;
55
+ }
56
+ if (key === "ArrowUp" || key === "\u001B[A" || key === "k") {
57
+ const skillCount = latestView[activeTabIndex]?.skills.length ?? 0;
58
+ if (skillCount > 0) {
59
+ selectedSkillIndexes[activeTabIndex] =
60
+ ((selectedSkillIndexes[activeTabIndex] ?? 0) - 1 + skillCount) % skillCount;
61
+ options.render?.(formatManageView(latestView, { activeTabIndex, selectedSkillIndexes }));
62
+ }
63
+ continue;
64
+ }
65
+ if (key === " " || key === "Enter" || key === "\r") {
66
+ const tab = latestView[activeTabIndex];
67
+ const selectedSkillIndex = selectedSkillIndexes[activeTabIndex] ?? 0;
68
+ const selectedSkill = tab?.skills[selectedSkillIndex];
69
+ const integration = integrations[activeTabIndex];
70
+ if (tab?.activated === true &&
71
+ integration !== undefined &&
72
+ selectedSkill !== undefined &&
73
+ (selectedSkill.state === "enabled" || selectedSkill.state === "disabled")) {
74
+ await toggleManagedSkill(integration, selectedSkill.skillIdentity);
75
+ latestView = await buildManageView(integrations);
76
+ selectedSkillIndexes[activeTabIndex] = Math.min(selectedSkillIndex, Math.max((latestView[activeTabIndex]?.skills.length ?? 1) - 1, 0));
77
+ options.render?.(formatManageView(latestView, { activeTabIndex, selectedSkillIndexes }));
78
+ }
79
+ }
80
+ }
81
+ }
82
+ export async function toggleManagedSkill(integration, skillIdentity) {
83
+ const [current] = (await scanIntegrationSkills(integration)).filter((skill) => skill.skillIdentity === skillIdentity);
84
+ if (current?.state === "enabled") {
85
+ return disableSkill(integration, skillIdentity);
86
+ }
87
+ if (current?.state === "disabled") {
88
+ return enableSkill(integration, skillIdentity);
89
+ }
90
+ return {
91
+ integrationId: integration.id,
92
+ skillIdentity,
93
+ status: current?.state ?? "not-installed",
94
+ };
95
+ }
@@ -0,0 +1,90 @@
1
+ import { cp, mkdtemp, readdir, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawn } from "node:child_process";
6
+ export async function resolveSkillArtifactWithCli(skillSpec, options = {}) {
7
+ const workspace = await mkdtemp(join(options.tempRoot ?? tmpdir(), "xcode-skills-"));
8
+ const removeWorkspace = options.removeWorkspace ??
9
+ ((path) => rm(path, { recursive: true, force: true }));
10
+ const runCommand = options.runCommand ?? spawnCommand;
11
+ const skillsCliPath = options.skillsCliPath ?? defaultSkillsCliPath();
12
+ try {
13
+ const invocation = {
14
+ executable: process.execPath,
15
+ args: [skillsCliPath, "add", skillSpec, "--copy", "--yes"],
16
+ cwd: workspace,
17
+ };
18
+ options.onVerbose?.(`workspace: ${workspace}`);
19
+ options.onVerbose?.(`command: ${invocation.executable} ${invocation.args.join(" ")}`);
20
+ const result = await runCommand(invocation);
21
+ if (result.stdout.length > 0) {
22
+ options.onVerbose?.(`stdout: ${result.stdout.trim()}`);
23
+ }
24
+ if (result.stderr.length > 0) {
25
+ options.onVerbose?.(`stderr: ${result.stderr.trim()}`);
26
+ }
27
+ if (result.exitCode !== 0) {
28
+ throw new Error(result.stderr || result.stdout || `skills add failed with exit code ${result.exitCode}`);
29
+ }
30
+ const skillRoot = join(workspace, ".agents", "skills");
31
+ const skillIdentities = (await readdir(skillRoot, { withFileTypes: true }))
32
+ .filter((entry) => entry.isDirectory())
33
+ .map((entry) => entry.name)
34
+ .sort();
35
+ if (skillIdentities.length !== 1) {
36
+ throw new Error(`Expected one resolved Skill Folder, found ${skillIdentities.length}`);
37
+ }
38
+ const skillIdentity = skillIdentities[0];
39
+ const artifactPath = join(skillRoot, skillIdentity);
40
+ await copyLockFileIfPresent(workspace, artifactPath);
41
+ return {
42
+ skillIdentity,
43
+ artifactPath,
44
+ cleanup: () => removeWorkspace(workspace),
45
+ };
46
+ }
47
+ catch (error) {
48
+ await removeWorkspace(workspace);
49
+ throw error;
50
+ }
51
+ }
52
+ async function copyLockFileIfPresent(workspace, artifactPath) {
53
+ try {
54
+ await cp(join(workspace, "skills-lock.json"), join(artifactPath, "skills-lock.json"));
55
+ }
56
+ catch (error) {
57
+ if (isNodeError(error) && error.code === "ENOENT") {
58
+ return;
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ function defaultSkillsCliPath() {
64
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "node_modules", "skills", "bin", "cli.mjs");
65
+ }
66
+ function spawnCommand(command) {
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn(command.executable, command.args, {
69
+ cwd: command.cwd,
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ });
72
+ let stdout = "";
73
+ let stderr = "";
74
+ child.stdout.setEncoding("utf8");
75
+ child.stderr.setEncoding("utf8");
76
+ child.stdout.on("data", (chunk) => {
77
+ stdout += chunk;
78
+ });
79
+ child.stderr.on("data", (chunk) => {
80
+ stderr += chunk;
81
+ });
82
+ child.on("error", reject);
83
+ child.on("close", (exitCode) => {
84
+ resolve({ exitCode: exitCode ?? 1, stdout, stderr });
85
+ });
86
+ });
87
+ }
88
+ function isNodeError(error) {
89
+ return error instanceof Error && "code" in error;
90
+ }
@@ -0,0 +1,22 @@
1
+ export async function selectTargetIntegrations(_options) {
2
+ const target = _options.target ??
3
+ (await (_options.promptForTarget ?? missingPromptForTarget)());
4
+ if (target === "both") {
5
+ return orderTargets(_options.integrations);
6
+ }
7
+ const integration = _options.integrations.find((item) => item.id === target);
8
+ if (integration === undefined) {
9
+ throw new Error(`Unknown Target Agent Integration: ${target}`);
10
+ }
11
+ if (!integration.activated) {
12
+ throw new Error(`${target} Agent Integration is not activated in Xcode`);
13
+ }
14
+ return [integration];
15
+ }
16
+ async function missingPromptForTarget() {
17
+ throw new Error("Target Agent Integration is required");
18
+ }
19
+ function orderTargets(integrations) {
20
+ const order = { codex: 0, claude: 1 };
21
+ return [...integrations].sort((left, right) => order[left.id] - order[right.id]);
22
+ }
@@ -0,0 +1,67 @@
1
+ import { join } from "node:path";
2
+ import { pathExists, removePath } from "./fs-utils.js";
3
+ export async function uninstallSkill(integration, skillIdentity, options = {}) {
4
+ if (!integration.activated) {
5
+ return {
6
+ integrationId: integration.id,
7
+ skillIdentity,
8
+ status: "not-activated",
9
+ removedPaths: [],
10
+ };
11
+ }
12
+ const activePath = join(integration.activeSkillsPath, skillIdentity);
13
+ const disabledPath = join(integration.disabledSkillsPath, skillIdentity);
14
+ const paths = [
15
+ ...(await pathExists(activePath) ? [activePath] : []),
16
+ ...(await pathExists(disabledPath) ? [disabledPath] : []),
17
+ ];
18
+ if (paths.length === 0) {
19
+ return {
20
+ integrationId: integration.id,
21
+ skillIdentity,
22
+ status: "not-installed",
23
+ removedPaths: [],
24
+ };
25
+ }
26
+ const needsConfirmation = await requiresConfirmation(paths);
27
+ if (options.dryRun === true && needsConfirmation) {
28
+ return {
29
+ integrationId: integration.id,
30
+ skillIdentity,
31
+ status: "would-need-confirmation",
32
+ removedPaths: paths,
33
+ };
34
+ }
35
+ if (options.dryRun === true) {
36
+ return {
37
+ integrationId: integration.id,
38
+ skillIdentity,
39
+ status: "would-uninstall",
40
+ removedPaths: paths,
41
+ };
42
+ }
43
+ if (options.yes !== true && needsConfirmation) {
44
+ return {
45
+ integrationId: integration.id,
46
+ skillIdentity,
47
+ status: "needs-confirmation",
48
+ removedPaths: paths,
49
+ };
50
+ }
51
+ await Promise.all(paths.map((path) => removePath(path)));
52
+ return {
53
+ integrationId: integration.id,
54
+ skillIdentity,
55
+ status: "uninstalled",
56
+ removedPaths: paths,
57
+ };
58
+ }
59
+ async function requiresConfirmation(paths) {
60
+ for (const path of paths) {
61
+ if (!(await pathExists(join(path, "skills-lock.json"))) ||
62
+ !(await pathExists(join(path, "SKILL.md")))) {
63
+ return true;
64
+ }
65
+ }
66
+ return false;
67
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "xcode-skills",
3
+ "version": "0.1.0",
4
+ "description": "Manage Agent Skills for Xcode's Codex and Claude Coding Assistant integrations.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "xcode-skills": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "prepublishOnly": "npm test && npm run typecheck && npm run build",
21
+ "test": "vitest run",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "skills": "^1.5.9"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.0.0",
29
+ "typescript": "^5.9.0",
30
+ "vitest": "^4.0.0"
31
+ }
32
+ }