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 +21 -0
- package/README.md +173 -0
- package/dist/cli.js +242 -0
- package/dist/filesystem-state.js +99 -0
- package/dist/fs-utils.js +43 -0
- package/dist/install.js +93 -0
- package/dist/integrations.js +34 -0
- package/dist/lifecycle.js +127 -0
- package/dist/list.js +43 -0
- package/dist/manage.js +95 -0
- package/dist/resolver.js +90 -0
- package/dist/targets.js +22 -0
- package/dist/uninstall.js +67 -0
- package/package.json +32 -0
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
|
+
}
|
package/dist/fs-utils.js
ADDED
|
@@ -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
|
+
}
|
package/dist/install.js
ADDED
|
@@ -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
|
+
}
|
package/dist/resolver.js
ADDED
|
@@ -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
|
+
}
|
package/dist/targets.js
ADDED
|
@@ -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
|
+
}
|