@michaelschnyder/teams-cli 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/dist/policy.js ADDED
@@ -0,0 +1,328 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, stat, writeFile, } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, matchesGlob, parse, resolve, } from "node:path";
4
+ import { stringify } from "yaml";
5
+ import { parseStrictYaml, rejectUnknownKeys, requireObject } from "./yaml.js";
6
+ const POLICY_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
7
+ function stringArray(value, label) {
8
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.length > 0)) {
9
+ throw new Error(`${label} must be an array of non-empty strings`);
10
+ }
11
+ return value;
12
+ }
13
+ function optionalString(value, label) {
14
+ if (value === undefined)
15
+ return undefined;
16
+ if (typeof value !== "string" || value.length === 0)
17
+ throw new Error(`${label} must be a non-empty string`);
18
+ return value;
19
+ }
20
+ function validatePolicyName(name) {
21
+ if (!POLICY_NAME.test(name)) {
22
+ throw new Error("Policy name must use 1-64 letters, numbers, dots, underscores, or hyphens");
23
+ }
24
+ return name;
25
+ }
26
+ function validateSubjectPaths(value) {
27
+ const paths = stringArray(value, "Policy.subject.paths");
28
+ if (paths.length === 0)
29
+ throw new Error("Policy.subject.paths must contain at least one path glob");
30
+ for (const pattern of paths) {
31
+ if (!isAbsolute(pattern))
32
+ throw new Error(`Policy subject path must be absolute: ${pattern}`);
33
+ try {
34
+ matchesGlob(resolve(pattern), pattern);
35
+ }
36
+ catch (error) {
37
+ const detail = error instanceof Error ? error.message : String(error);
38
+ throw new Error(`Invalid policy subject path glob ${pattern}: ${detail}`);
39
+ }
40
+ }
41
+ return paths;
42
+ }
43
+ export function parsePolicy(value) {
44
+ const root = requireObject(value, "Policy");
45
+ rejectUnknownKeys(root, ["version", "name", "active", "subject", "identity", "allow"], "Policy");
46
+ if (root.version !== 1)
47
+ throw new Error("Policy version must be 1");
48
+ if (typeof root.name !== "string")
49
+ throw new Error("Policy.name must be a string");
50
+ const name = validatePolicyName(root.name);
51
+ if (typeof root.active !== "boolean")
52
+ throw new Error("Policy.active must be a boolean");
53
+ const rawSubject = requireObject(root.subject, "Policy.subject");
54
+ rejectUnknownKeys(rawSubject, ["paths"], "Policy.subject");
55
+ const subject = { paths: validateSubjectPaths(rawSubject.paths) };
56
+ let identity;
57
+ if (root.identity !== undefined) {
58
+ const raw = requireObject(root.identity, "Policy.identity");
59
+ rejectUnknownKeys(raw, ["tenantId", "userId"], "Policy.identity");
60
+ const tenantId = optionalString(raw.tenantId, "Policy.identity.tenantId");
61
+ const userId = optionalString(raw.userId, "Policy.identity.userId");
62
+ identity = { ...(tenantId ? { tenantId } : {}), ...(userId ? { userId } : {}) };
63
+ }
64
+ let allow;
65
+ if (root.allow !== undefined) {
66
+ const raw = requireObject(root.allow, "Policy.allow");
67
+ rejectUnknownKeys(raw, ["messageSend", "rawTokenExport"], "Policy.allow");
68
+ let messageSend;
69
+ if (raw.messageSend !== undefined) {
70
+ const message = requireObject(raw.messageSend, "Policy.allow.messageSend");
71
+ rejectUnknownKeys(message, ["chats", "channels"], "Policy.allow.messageSend");
72
+ messageSend = {
73
+ chats: stringArray(message.chats, "Policy.allow.messageSend.chats"),
74
+ channels: stringArray(message.channels, "Policy.allow.messageSend.channels"),
75
+ };
76
+ }
77
+ if (raw.rawTokenExport !== undefined && typeof raw.rawTokenExport !== "boolean") {
78
+ throw new Error("Policy.allow.rawTokenExport must be a boolean");
79
+ }
80
+ allow = {
81
+ ...(messageSend ? { messageSend } : {}),
82
+ ...(typeof raw.rawTokenExport === "boolean" ? { rawTokenExport: raw.rawTokenExport } : {}),
83
+ };
84
+ }
85
+ return {
86
+ version: 1,
87
+ name,
88
+ active: root.active,
89
+ subject,
90
+ ...(identity ? { identity } : {}),
91
+ ...(allow ? { allow } : {}),
92
+ };
93
+ }
94
+ async function exists(path) {
95
+ try {
96
+ await lstat(path);
97
+ return true;
98
+ }
99
+ catch (error) {
100
+ if (error.code === "ENOENT")
101
+ return false;
102
+ throw error;
103
+ }
104
+ }
105
+ export async function canonicalSubjectPath(start = process.cwd()) {
106
+ return realpath(resolve(start));
107
+ }
108
+ export function policyFile(paths, name) {
109
+ return join(paths.policiesDirectory, `${validatePolicyName(name)}.yaml`);
110
+ }
111
+ function containsGlob(value) {
112
+ return /[*?\[\]{}()!]/.test(value);
113
+ }
114
+ async function canonicalSubjectPattern(pattern) {
115
+ const root = parse(pattern).root;
116
+ const segments = pattern.slice(root.length).split(process.platform === "win32" ? /[\\/]/ : "/");
117
+ const firstGlob = segments.findIndex(containsGlob);
118
+ if (firstGlob === -1) {
119
+ try {
120
+ return await realpath(pattern);
121
+ }
122
+ catch {
123
+ return resolve(pattern);
124
+ }
125
+ }
126
+ const prefix = join(root, ...segments.slice(0, firstGlob));
127
+ const suffix = segments.slice(firstGlob);
128
+ let canonicalPrefix;
129
+ try {
130
+ canonicalPrefix = await realpath(prefix);
131
+ }
132
+ catch {
133
+ canonicalPrefix = resolve(prefix);
134
+ }
135
+ return suffix.length > 0 ? join(canonicalPrefix, ...suffix) : canonicalPrefix;
136
+ }
137
+ function ownerCanWrite(fileStats) {
138
+ const getuid = process.getuid;
139
+ return process.platform !== "win32" && typeof getuid === "function" &&
140
+ fileStats.uid === getuid() && (fileStats.mode & 0o200) !== 0;
141
+ }
142
+ async function loadPolicyFile(file, filename) {
143
+ let raw;
144
+ try {
145
+ raw = await readFile(file, "utf8");
146
+ }
147
+ catch {
148
+ throw new Error(`Policy denied operation: cannot read ${file}`);
149
+ }
150
+ let policy;
151
+ try {
152
+ policy = parsePolicy(parseStrictYaml(raw, file));
153
+ }
154
+ catch (error) {
155
+ const detail = error instanceof Error ? error.message : String(error);
156
+ throw new Error(`Policy denied operation: ${detail}`);
157
+ }
158
+ if (filename !== `${policy.name}.yaml`) {
159
+ throw new Error(`Policy denied operation: ${file} does not match policy name ${policy.name}`);
160
+ }
161
+ const permissionWarnings = [];
162
+ if (policy.active && process.platform !== "win32") {
163
+ let fileStats;
164
+ try {
165
+ fileStats = await stat(file);
166
+ }
167
+ catch {
168
+ throw new Error(`Policy denied operation: cannot inspect permissions for ${file}`);
169
+ }
170
+ if ((fileStats.mode & 0o022) !== 0) {
171
+ throw new Error(`Policy denied operation: active policy ${file} is writable by group or other users`);
172
+ }
173
+ if (ownerCanWrite(fileStats)) {
174
+ permissionWarnings.push(`Active policy ${policy.name} is owner-writable at ${file}; make it read-only outside the CLI`);
175
+ }
176
+ }
177
+ const canonicalSubjectPatterns = await Promise.all(policy.subject.paths.map(canonicalSubjectPattern));
178
+ return { file, policy, canonicalSubjectPatterns, permissionWarnings };
179
+ }
180
+ export async function loadPolicyStore(paths) {
181
+ let entries;
182
+ try {
183
+ entries = await readdir(paths.policiesDirectory, { withFileTypes: true });
184
+ }
185
+ catch (error) {
186
+ if (error.code === "ENOENT")
187
+ return [];
188
+ throw new Error(`Policy denied operation: cannot inspect ${paths.policiesDirectory}`);
189
+ }
190
+ if (process.platform !== "win32") {
191
+ let directoryStats;
192
+ try {
193
+ directoryStats = await stat(paths.policiesDirectory);
194
+ }
195
+ catch {
196
+ throw new Error(`Policy denied operation: cannot inspect permissions for ${paths.policiesDirectory}`);
197
+ }
198
+ if ((directoryStats.mode & 0o022) !== 0) {
199
+ throw new Error(`Policy denied operation: policy directory ${paths.policiesDirectory} is writable by group or other users`);
200
+ }
201
+ }
202
+ const policies = [];
203
+ for (const entry of entries.filter(({ name }) => name.endsWith(".yaml")).sort((a, b) => a.name.localeCompare(b.name))) {
204
+ const file = join(paths.policiesDirectory, entry.name);
205
+ if (!entry.isFile()) {
206
+ throw new Error(`Policy denied operation: ${file} is not a regular policy file`);
207
+ }
208
+ policies.push(await loadPolicyFile(file, entry.name));
209
+ }
210
+ return policies;
211
+ }
212
+ function appliesToPath(record, subjectPath) {
213
+ return record.canonicalSubjectPatterns.some((pattern) => matchesGlob(subjectPath, pattern));
214
+ }
215
+ export async function resolvePolicies(paths, start = process.cwd()) {
216
+ const subjectPath = await canonicalSubjectPath(start);
217
+ const policies = (await loadPolicyStore(paths)).filter((record) => appliesToPath(record, subjectPath));
218
+ return { subjectPath, policies };
219
+ }
220
+ export async function resolvePolicyByName(paths, name) {
221
+ const file = policyFile(paths, name);
222
+ const policy = (await loadPolicyStore(paths)).find((record) => record.file === file);
223
+ if (!policy)
224
+ throw new Error(`Policy ${name} does not exist at ${file}`);
225
+ return policy;
226
+ }
227
+ export function policyStatusWarnings(resolved) {
228
+ return resolved.policies.flatMap((record) => [
229
+ ...(!record.policy.active
230
+ ? [`Policy ${record.policy.name} applies to ${resolved.subjectPath} but is inactive and not enforcing restrictions`]
231
+ : []),
232
+ ...record.permissionWarnings,
233
+ ]);
234
+ }
235
+ function identityDenial(policy, identity) {
236
+ if (policy.identity?.tenantId && policy.identity.tenantId !== identity.tenantId) {
237
+ return `tenant ${identity.tenantId} is not allowed`;
238
+ }
239
+ if (policy.identity?.userId && policy.identity.userId !== identity.userId) {
240
+ return `user ${identity.userId} is not allowed`;
241
+ }
242
+ return undefined;
243
+ }
244
+ function evaluate(resolved, denial) {
245
+ const warnings = [];
246
+ for (const { policy } of resolved.policies) {
247
+ const reason = denial(policy);
248
+ if (!reason)
249
+ continue;
250
+ if (policy.active)
251
+ throw new Error(`Policy ${policy.name} denied operation: ${reason}`);
252
+ warnings.push(`Inactive policy ${policy.name} would deny operation: ${reason}`);
253
+ }
254
+ return warnings;
255
+ }
256
+ export function requirePolicyIdentity(resolved, identity) {
257
+ return evaluate(resolved, (policy) => identityDenial(policy, identity));
258
+ }
259
+ export function requireMessageSend(resolved, identity, target) {
260
+ return evaluate(resolved, (policy) => {
261
+ const identityReason = identityDenial(policy, identity);
262
+ if (identityReason)
263
+ return identityReason;
264
+ const allowed = policy.allow?.messageSend;
265
+ const identifiers = target.kind === "chat" ? allowed?.chats : allowed?.channels;
266
+ return identifiers?.includes(target.id)
267
+ ? undefined
268
+ : `${target.kind} ${target.id} is not allowlisted`;
269
+ });
270
+ }
271
+ export function requireRawTokenExport(resolved, identity) {
272
+ return evaluate(resolved, (policy) => {
273
+ const identityReason = identityDenial(policy, identity);
274
+ if (identityReason)
275
+ return identityReason;
276
+ return policy.allow?.rawTokenExport === true ? undefined : "raw token export is not allowed";
277
+ });
278
+ }
279
+ export async function initializePolicy(paths, name, context, subjectPaths = [], start = process.cwd()) {
280
+ await loadPolicyStore(paths);
281
+ if (!context.tenantId || !context.userId) {
282
+ throw new Error("Policy initialization requires an effective tenant and user");
283
+ }
284
+ const file = policyFile(paths, name);
285
+ if (await exists(file))
286
+ throw new Error(`Policy ${name} already exists at ${file}`);
287
+ const subjectPath = await canonicalSubjectPath(start);
288
+ const pathsToStore = subjectPaths.length > 0
289
+ ? validateSubjectPaths([...subjectPaths])
290
+ : [subjectPath, join(subjectPath, "**")];
291
+ const policy = {
292
+ version: 1,
293
+ name,
294
+ active: false,
295
+ subject: { paths: pathsToStore },
296
+ identity: { tenantId: context.tenantId, userId: context.userId },
297
+ allow: { messageSend: { chats: [], channels: [] }, rawTokenExport: false },
298
+ };
299
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
300
+ await chmod(dirname(file), 0o700);
301
+ await writeFile(file, stringify(policy), { mode: 0o600 });
302
+ await chmod(file, 0o600);
303
+ return {
304
+ file,
305
+ policy,
306
+ canonicalSubjectPatterns: await Promise.all(pathsToStore.map(canonicalSubjectPattern)),
307
+ permissionWarnings: [],
308
+ };
309
+ }
310
+ export async function activatePolicy(record) {
311
+ if (!record.policy.active) {
312
+ const policy = { ...record.policy, active: true };
313
+ const temporary = `${record.file}.${randomUUID()}.tmp`;
314
+ await writeFile(temporary, stringify(policy), { mode: 0o600 });
315
+ await rename(temporary, record.file);
316
+ await chmod(record.file, 0o600);
317
+ record = { ...record, policy };
318
+ }
319
+ return record;
320
+ }
321
+ function shellQuote(value) {
322
+ return `'${value.replaceAll("'", `'\\''`)}'`;
323
+ }
324
+ export function policyProtectionInstruction(file) {
325
+ if (process.platform === "win32")
326
+ return null;
327
+ return `chmod 400 -- ${shellQuote(file)}`;
328
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: teams-authentication
3
+ description: Authenticate teams-cli, select profiles, validate identity, and handle stored credentials safely.
4
+ license: MIT
5
+ metadata:
6
+ version: "0.1.0"
7
+ author: teams-cli
8
+ ---
9
+
10
+ # Teams CLI authentication
11
+
12
+ Interactive login is the normal path:
13
+
14
+ ```bash
15
+ teams-cli --profile work --tenant <tenant-id> auth login
16
+ teams-cli --profile work auth whoami
17
+ ```
18
+
19
+ The successful login stores identity-scoped tokens and an isolated browser profile under `~/.teams-cli/`. Profiles are configuration defaults, not security boundaries.
20
+
21
+ Useful commands:
22
+
23
+ ```bash
24
+ teams-cli profile list
25
+ teams-cli profile show work
26
+ teams-cli --profile work auth refresh
27
+ teams-cli --profile work auth logout
28
+ ```
29
+
30
+ Do not request, display, or export raw tokens unless the user explicitly asks and the applicable policy permits it. Never weaken MFA, conditional access, or tenant security controls. Automated login must use an explicit absolute `--password-command`; passwords are not stored by the CLI.
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: teams-cli
3
+ description: Use teams-cli safely for Microsoft Teams authentication, discovery, reading, messaging, profiles, and policies.
4
+ license: MIT
5
+ metadata:
6
+ version: "0.1.0"
7
+ author: teams-cli
8
+ ---
9
+
10
+ # Teams CLI
11
+
12
+ Use `teams-cli` when a task requires command-line access to Microsoft Teams. The CLI uses an authenticated local Edge or Chrome profile and undocumented Microsoft APIs, so confirm organizational approval before use.
13
+
14
+ ## Discover commands
15
+
16
+ ```bash
17
+ teams-cli --help
18
+ teams-cli auth --help
19
+ teams-cli person --help
20
+ teams-cli chat --help
21
+ teams-cli channel --help
22
+ teams-cli message --help
23
+ teams-cli policy --help
24
+ ```
25
+
26
+ Use `--profile <name>` to select a configured tenant and user. Prefer `--json` for machine-readable person, chat, channel, and message results. Keep stdout available for payloads; warnings and diagnostics use stderr.
27
+
28
+ ## Safe workflow
29
+
30
+ 1. Verify the selected identity with `teams-cli --profile <name> auth whoami`.
31
+ 2. Discover people, chats, or channels before using identifiers.
32
+ 3. Read the applicable policy with `teams-cli policy show`.
33
+ 4. Check a write target with `teams-cli policy check send` before sending.
34
+ 5. Never copy bearer tokens into prompts, logs, or source files.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: teams-messaging-policies
3
+ description: Send Teams messages with teams-cli only after verifying identity, target, and applicable workspace policies.
4
+ license: MIT
5
+ metadata:
6
+ version: "0.1.0"
7
+ author: teams-cli
8
+ ---
9
+
10
+ # Teams messaging and policies
11
+
12
+ Sending is externally visible and must be intentional. Before a send:
13
+
14
+ 1. Run `teams-cli auth whoami` for the selected profile.
15
+ 2. Resolve the chat or channel from a fresh list operation.
16
+ 3. Run `teams-cli policy show` and `teams-cli policy check send --chat <id>` or `--channel <id>`.
17
+ 4. Confirm the body and target with the user when either is ambiguous.
18
+
19
+ Send plain text with exactly one target:
20
+
21
+ ```bash
22
+ teams-cli --profile work message send --chat <chat-id> --body "Hello"
23
+ printf '%s' "Hello" | teams-cli --profile work message send --channel <channel-id>
24
+ ```
25
+
26
+ Never bypass a policy denial by calling Microsoft APIs directly or exporting a token. Inactive policies warn but do not enforce; active matching policies intersect. A malformed policy store fails closed for authenticated operations.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: teams-reading
3
+ description: Discover people, chats, channels, and messages through teams-cli with structured output and pagination.
4
+ license: MIT
5
+ metadata:
6
+ version: "0.1.0"
7
+ author: teams-cli
8
+ ---
9
+
10
+ # Read Microsoft Teams data
11
+
12
+ Verify the profile before reading data:
13
+
14
+ ```bash
15
+ teams-cli --profile work auth whoami
16
+ teams-cli --profile work person search "Alice" --json
17
+ teams-cli --profile work chat list --json
18
+ teams-cli --profile work channel list --json
19
+ ```
20
+
21
+ Read messages from exactly one target type:
22
+
23
+ ```bash
24
+ teams-cli --profile work message list --chat <chat-id> --json
25
+ teams-cli --profile work message list --channel <channel-id> --json
26
+ teams-cli --profile work message get <message-id> --chat <chat-id> --json
27
+ ```
28
+
29
+ When a result contains a cursor, pass it unchanged with `--cursor`. Do not combine a message cursor with `--page-size`. Treat names as display data and use returned IDs for follow-up operations.
package/dist/skills.js ADDED
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, readdir, rename, stat, writeFile, } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ export const SKILL_PLATFORMS = [
8
+ { name: "codex", aliases: [], projectDirectory: ".codex/skills", userDirectory: ".codex/skills", markers: [".codex"] },
9
+ { name: "claude-code", aliases: ["claude"], projectDirectory: ".claude/skills", userDirectory: ".claude/skills", markers: [".claude"] },
10
+ { name: "cursor", aliases: [], projectDirectory: ".cursor/skills", userDirectory: ".cursor/skills", markers: [".cursor"] },
11
+ { name: "github-copilot", aliases: ["copilot"], projectDirectory: ".github/skills", userDirectory: ".copilot/skills", markers: [".github/skills", ".copilot"] },
12
+ { name: "opencode", aliases: [], projectDirectory: ".opencode/skills", userDirectory: ".config/opencode/skills", markers: [".opencode", ".config/opencode"] },
13
+ { name: "windsurf", aliases: [], projectDirectory: ".windsurf/skills", userDirectory: ".windsurf/skills", markers: [".windsurf"] },
14
+ { name: "gemini-cli", aliases: ["gemini"], projectDirectory: ".gemini/skills", userDirectory: ".gemini/skills", markers: [".gemini"] },
15
+ { name: "pi", aliases: ["pi-dev"], projectDirectory: ".pi/skills", userDirectory: ".pi/agent/skills", markers: [".pi"] },
16
+ { name: "agents", aliases: ["agent-skills"], projectDirectory: ".agents/skills", userDirectory: ".agents/skills", markers: [".agents"] },
17
+ ];
18
+ export function skillManifestFile(storageRoot = join(homedir(), ".teams-cli")) {
19
+ return join(storageRoot, "skill-installations.json");
20
+ }
21
+ export function findProjectRoot(start = process.cwd()) {
22
+ let current = resolve(start);
23
+ while (true) {
24
+ if (existsSync(join(current, ".git")))
25
+ return current;
26
+ const parent = dirname(current);
27
+ if (parent === current)
28
+ return resolve(start);
29
+ current = parent;
30
+ }
31
+ }
32
+ export function lookupSkillPlatform(input) {
33
+ const normalized = input.trim().toLowerCase();
34
+ return SKILL_PLATFORMS.find(({ name, aliases }) => name === normalized || aliases.includes(normalized));
35
+ }
36
+ export function detectSkillPlatforms(projectRoot = findProjectRoot(), userHome = homedir()) {
37
+ return SKILL_PLATFORMS.filter(({ markers }) => markers.some((marker) => existsSync(join(projectRoot, ...marker.split("/"))) || existsSync(join(userHome, ...marker.split("/")))));
38
+ }
39
+ export function skillDestination(platform, project, projectRoot = findProjectRoot(), userHome = homedir()) {
40
+ const relative = project ? platform.projectDirectory : platform.userDirectory;
41
+ return join(project ? projectRoot : userHome, ...relative.split("/"));
42
+ }
43
+ function skillsResourceRoot() {
44
+ return join(dirname(fileURLToPath(import.meta.url)), "skills");
45
+ }
46
+ export async function loadBundledSkills() {
47
+ const root = skillsResourceRoot();
48
+ const entries = await readdir(root, { withFileTypes: true });
49
+ const skills = [];
50
+ for (const entry of entries.filter((candidate) => candidate.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
51
+ const content = await readFile(join(root, entry.name, "SKILL.md"), "utf8");
52
+ const description = /^description:\s*(.+)$/m.exec(content)?.[1]?.trim();
53
+ if (!description)
54
+ throw new Error(`Skill ${entry.name} has no description`);
55
+ skills.push({ name: entry.name, description, content });
56
+ }
57
+ return skills;
58
+ }
59
+ export async function loadSkillManifest(file = skillManifestFile()) {
60
+ try {
61
+ const parsed = JSON.parse(await readFile(file, "utf8"));
62
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1) {
63
+ throw new Error("Skill installation manifest is invalid");
64
+ }
65
+ const installations = parsed.installations;
66
+ if (!Array.isArray(installations) || !installations.every((item) => {
67
+ if (!item || typeof item !== "object")
68
+ return false;
69
+ const candidate = item;
70
+ return typeof candidate.destination === "string" && Array.isArray(candidate.skillNames) &&
71
+ candidate.skillNames.every((name) => typeof name === "string");
72
+ }))
73
+ throw new Error("Skill installation manifest is invalid");
74
+ return { version: 1, installations: installations };
75
+ }
76
+ catch (error) {
77
+ if (error.code === "ENOENT")
78
+ return { version: 1, installations: [] };
79
+ throw error;
80
+ }
81
+ }
82
+ async function saveSkillManifest(manifest, file) {
83
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
84
+ await chmod(dirname(file), 0o700);
85
+ const temporary = `${file}.${randomUUID()}.tmp`;
86
+ await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
87
+ await rename(temporary, file);
88
+ await chmod(file, 0o600);
89
+ }
90
+ export async function installSkills(options) {
91
+ const bundled = await loadBundledSkills();
92
+ const selected = options.names?.length
93
+ ? bundled.filter(({ name }) => options.names?.includes(name))
94
+ : bundled;
95
+ if (options.names?.length && selected.length !== new Set(options.names).size) {
96
+ const known = new Set(bundled.map(({ name }) => name));
97
+ const missing = options.names.filter((name) => !known.has(name));
98
+ throw new Error(`Unknown skill${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
99
+ }
100
+ const destinations = [...new Set(options.destinations.map((destination) => resolve(destination)))];
101
+ let filesWritten = 0;
102
+ const managed = new Map();
103
+ for (const destination of destinations) {
104
+ for (const skill of selected) {
105
+ const directory = join(destination, skill.name);
106
+ const file = join(directory, "SKILL.md");
107
+ await mkdir(directory, { recursive: true });
108
+ let exists = false;
109
+ try {
110
+ await stat(file);
111
+ exists = true;
112
+ }
113
+ catch (error) {
114
+ if (error.code !== "ENOENT")
115
+ throw error;
116
+ }
117
+ if (exists && !options.force)
118
+ continue;
119
+ await writeFile(file, skill.content, "utf8");
120
+ filesWritten += 1;
121
+ const names = managed.get(destination) ?? new Set();
122
+ names.add(skill.name);
123
+ managed.set(destination, names);
124
+ }
125
+ }
126
+ if (managed.size) {
127
+ const file = options.manifestFile ?? skillManifestFile();
128
+ const manifest = await loadSkillManifest(file);
129
+ const records = new Map(manifest.installations.map((entry) => [entry.destination, new Set(entry.skillNames)]));
130
+ for (const [destination, names] of managed) {
131
+ const existing = records.get(destination) ?? new Set();
132
+ for (const name of names)
133
+ existing.add(name);
134
+ records.set(destination, existing);
135
+ }
136
+ await saveSkillManifest({
137
+ version: 1,
138
+ installations: [...records.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([destination, names]) => ({ destination, skillNames: [...names].sort() })),
139
+ }, file);
140
+ }
141
+ return { filesWritten, destinations };
142
+ }
143
+ export async function reinstallSkills(manifestFile = skillManifestFile()) {
144
+ const manifest = await loadSkillManifest(manifestFile);
145
+ let filesWritten = 0;
146
+ for (const installation of manifest.installations) {
147
+ const result = await installSkills({
148
+ destinations: [installation.destination],
149
+ names: installation.skillNames,
150
+ force: true,
151
+ manifestFile,
152
+ });
153
+ filesWritten += result.filesWritten;
154
+ }
155
+ return { filesWritten, installations: manifest.installations.length };
156
+ }