dryai 0.2.1

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.
@@ -0,0 +1,104 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ export const DEFAULT_INPUT_ROOT_SEGMENTS = ['.config', 'agents'];
4
+ export const DEFAULT_TEST_OUTPUT_DIR_NAME = 'output-test';
5
+ export const DEFAULT_SOURCE_ROOT_NAMES = {
6
+ commands: 'commands',
7
+ rules: 'rules',
8
+ skills: 'skills',
9
+ };
10
+ export const DEFAULT_TARGET_ROOT_SEGMENTS = {
11
+ copilotPrompts: ['.copilot', 'prompts'],
12
+ copilotInstructions: ['.copilot', 'instructions'],
13
+ copilotSkills: ['.copilot', 'skills'],
14
+ cursorRules: ['.cursor', 'rules'],
15
+ cursorSkills: ['.cursor', 'skills'],
16
+ };
17
+ /**
18
+ * Creates the Copilot and Cursor output root paths under one base directory.
19
+ */
20
+ export function createTargetRoots(baseDir) {
21
+ return {
22
+ copilotPrompts: path.join(baseDir, ...DEFAULT_TARGET_ROOT_SEGMENTS.copilotPrompts),
23
+ copilotInstructions: path.join(baseDir, ...DEFAULT_TARGET_ROOT_SEGMENTS.copilotInstructions),
24
+ copilotSkills: path.join(baseDir, ...DEFAULT_TARGET_ROOT_SEGMENTS.copilotSkills),
25
+ cursorRules: path.join(baseDir, ...DEFAULT_TARGET_ROOT_SEGMENTS.cursorRules),
26
+ cursorSkills: path.join(baseDir, ...DEFAULT_TARGET_ROOT_SEGMENTS.cursorSkills),
27
+ };
28
+ }
29
+ /**
30
+ * Creates the commands, rules, and skills input roots under one base directory.
31
+ */
32
+ export function createSourceRoots(baseDir) {
33
+ return {
34
+ commands: path.join(baseDir, DEFAULT_SOURCE_ROOT_NAMES.commands),
35
+ rules: path.join(baseDir, DEFAULT_SOURCE_ROOT_NAMES.rules),
36
+ skills: path.join(baseDir, DEFAULT_SOURCE_ROOT_NAMES.skills),
37
+ };
38
+ }
39
+ /**
40
+ * Expands a leading `~` in a path to the current user's home directory.
41
+ */
42
+ export function expandHomePath(inputPath, homeDir) {
43
+ if (inputPath === '~') {
44
+ return homeDir;
45
+ }
46
+ if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
47
+ return path.join(homeDir, inputPath.slice(2));
48
+ }
49
+ return inputPath;
50
+ }
51
+ /**
52
+ * Returns the requested output-root override derived from CLI-style options.
53
+ */
54
+ export function resolveRequestedOutputRoot(input) {
55
+ if (input.outputRoot) {
56
+ return input.outputRoot;
57
+ }
58
+ return input.test ? `./${DEFAULT_TEST_OUTPUT_DIR_NAME}` : undefined;
59
+ }
60
+ /**
61
+ * Returns the filesystem path to use for generated output.
62
+ */
63
+ export function resolveOutputRoot(input) {
64
+ if (input.outputRoot) {
65
+ return path.resolve(expandHomePath(input.outputRoot, input.homeDir));
66
+ }
67
+ return input.homeDir;
68
+ }
69
+ /**
70
+ * Returns a copy of the context with generated output redirected under one
71
+ * explicit output root.
72
+ */
73
+ export function resolveOutputContext(context, outputRoot) {
74
+ return {
75
+ ...context,
76
+ outputRoot,
77
+ targetRoots: createTargetRoots(outputRoot),
78
+ };
79
+ }
80
+ /**
81
+ * Creates the base CLI context with repository, input, and output paths
82
+ * resolved.
83
+ */
84
+ export function createAgentsContext(options) {
85
+ const homeDir = os.homedir();
86
+ const inputRoot = options?.inputRoot
87
+ ? path.resolve(expandHomePath(options.inputRoot, homeDir))
88
+ : path.join(homeDir, ...DEFAULT_INPUT_ROOT_SEGMENTS);
89
+ const outputRoot = resolveOutputRoot(options?.outputRoot
90
+ ? {
91
+ homeDir,
92
+ outputRoot: options.outputRoot,
93
+ }
94
+ : {
95
+ homeDir,
96
+ });
97
+ return {
98
+ inputRoot,
99
+ outputRoot,
100
+ skillsLockfilePath: path.join(inputRoot, 'skills.lock.json'),
101
+ sourceRoots: createSourceRoots(inputRoot),
102
+ targetRoots: createTargetRoots(outputRoot),
103
+ };
104
+ }
@@ -0,0 +1,104 @@
1
+ import { z } from 'zod';
2
+ export declare const nonEmptyStringSchema: z.ZodString;
3
+ export declare const commandFrontmatterSchema: z.ZodObject<{
4
+ name: z.ZodString;
5
+ description: z.ZodString;
6
+ cursor: z.ZodOptional<z.ZodObject<{
7
+ 'disable-model-invocation': z.ZodOptional<z.ZodBoolean>;
8
+ }, "strict", z.ZodTypeAny, {
9
+ 'disable-model-invocation'?: boolean | undefined;
10
+ }, {
11
+ 'disable-model-invocation'?: boolean | undefined;
12
+ }>>;
13
+ }, "strict", z.ZodTypeAny, {
14
+ name: string;
15
+ description: string;
16
+ cursor?: {
17
+ 'disable-model-invocation'?: boolean | undefined;
18
+ } | undefined;
19
+ }, {
20
+ name: string;
21
+ description: string;
22
+ cursor?: {
23
+ 'disable-model-invocation'?: boolean | undefined;
24
+ } | undefined;
25
+ }>;
26
+ export declare const ruleFrontmatterSchema: z.ZodObject<{
27
+ description: z.ZodString;
28
+ copilot: z.ZodObject<{
29
+ applyTo: z.ZodString;
30
+ }, "strict", z.ZodTypeAny, {
31
+ applyTo: string;
32
+ }, {
33
+ applyTo: string;
34
+ }>;
35
+ cursor: z.ZodOptional<z.ZodObject<{
36
+ alwaysApply: z.ZodOptional<z.ZodBoolean>;
37
+ globs: z.ZodOptional<z.ZodString>;
38
+ }, "strict", z.ZodTypeAny, {
39
+ alwaysApply?: boolean | undefined;
40
+ globs?: string | undefined;
41
+ }, {
42
+ alwaysApply?: boolean | undefined;
43
+ globs?: string | undefined;
44
+ }>>;
45
+ }, "strict", z.ZodTypeAny, {
46
+ description: string;
47
+ copilot: {
48
+ applyTo: string;
49
+ };
50
+ cursor?: {
51
+ alwaysApply?: boolean | undefined;
52
+ globs?: string | undefined;
53
+ } | undefined;
54
+ }, {
55
+ description: string;
56
+ copilot: {
57
+ applyTo: string;
58
+ };
59
+ cursor?: {
60
+ alwaysApply?: boolean | undefined;
61
+ globs?: string | undefined;
62
+ } | undefined;
63
+ }>;
64
+ export type CommandFrontmatter = z.infer<typeof commandFrontmatterSchema>;
65
+ export type RuleFrontmatter = z.infer<typeof ruleFrontmatterSchema>;
66
+ /**
67
+ * Returns a copy of an object with all undefined-valued entries removed.
68
+ */
69
+ export declare function compactObject(value: Record<string, unknown>): Record<string, unknown>;
70
+ /**
71
+ * Returns whether a value is a non-null plain object and not an array.
72
+ */
73
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
74
+ /**
75
+ * Parses optional YAML frontmatter from a markdown-like file and returns its metadata and body.
76
+ */
77
+ export declare function parseFrontmatter(fileContent: string): {
78
+ metadata: Record<string, unknown>;
79
+ body: string;
80
+ };
81
+ /**
82
+ * Validates parsed frontmatter against a schema and logs a skip message when validation fails.
83
+ */
84
+ export declare function validateFrontmatter<T>({ filePath, metadata, schema, }: {
85
+ filePath: string;
86
+ metadata: Record<string, unknown>;
87
+ schema: z.ZodType<T>;
88
+ }): T | null;
89
+ /**
90
+ * Normalizes rule frontmatter into the apply settings used by downstream generators.
91
+ */
92
+ export declare function normalizeRuleMetadata(metadata: RuleFrontmatter): {
93
+ alwaysApply: boolean;
94
+ globs: string | undefined;
95
+ applyTo: string;
96
+ };
97
+ /**
98
+ * Renders metadata and markdown body content back into a frontmatter document string.
99
+ */
100
+ export declare function renderMarkdown({ metadata, body, }: {
101
+ metadata: Record<string, unknown>;
102
+ body: string;
103
+ }): string;
104
+ //# sourceMappingURL=frontmatter.d.ts.map
@@ -0,0 +1,96 @@
1
+ import matter from 'gray-matter';
2
+ import { z } from 'zod';
3
+ export const nonEmptyStringSchema = z.string().trim().min(1);
4
+ export const commandFrontmatterSchema = z
5
+ .object({
6
+ name: nonEmptyStringSchema,
7
+ description: nonEmptyStringSchema,
8
+ cursor: z
9
+ .object({
10
+ 'disable-model-invocation': z.boolean().optional(),
11
+ })
12
+ .strict()
13
+ .optional(),
14
+ })
15
+ .strict();
16
+ export const ruleFrontmatterSchema = z
17
+ .object({
18
+ description: nonEmptyStringSchema,
19
+ copilot: z
20
+ .object({
21
+ applyTo: nonEmptyStringSchema,
22
+ })
23
+ .strict(),
24
+ cursor: z
25
+ .object({
26
+ alwaysApply: z.boolean().optional(),
27
+ globs: nonEmptyStringSchema.optional(),
28
+ })
29
+ .strict()
30
+ .optional(),
31
+ })
32
+ .strict();
33
+ /**
34
+ * Returns a copy of an object with all undefined-valued entries removed.
35
+ */
36
+ export function compactObject(value) {
37
+ return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
38
+ }
39
+ /**
40
+ * Returns whether a value is a non-null plain object and not an array.
41
+ */
42
+ export function isPlainObject(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+ /**
46
+ * Parses optional YAML frontmatter from a markdown-like file and returns its metadata and body.
47
+ */
48
+ export function parseFrontmatter(fileContent) {
49
+ const parsed = matter(fileContent);
50
+ return {
51
+ metadata: isPlainObject(parsed.data) ? parsed.data : {},
52
+ body: parsed.content.trim(),
53
+ };
54
+ }
55
+ /**
56
+ * Validates parsed frontmatter against a schema and logs a skip message when validation fails.
57
+ */
58
+ export function validateFrontmatter({ filePath, metadata, schema, }) {
59
+ const result = schema.safeParse(metadata);
60
+ if (result.success) {
61
+ return result.data;
62
+ }
63
+ const issues = result.error.issues
64
+ .map((issue) => {
65
+ const fieldPath = issue.path.length > 0 ? issue.path.join('.') : 'frontmatter';
66
+ return `${fieldPath}: ${issue.message}`;
67
+ })
68
+ .join('; ');
69
+ console.log(`Skipping invalid frontmatter in ${filePath}: ${issues}`);
70
+ return null;
71
+ }
72
+ /**
73
+ * Normalizes rule frontmatter into the apply settings used by downstream generators.
74
+ */
75
+ export function normalizeRuleMetadata(metadata) {
76
+ const copilotApplyTo = metadata.copilot.applyTo;
77
+ const explicitAlwaysApply = metadata.cursor?.alwaysApply;
78
+ const scopedGlobs = metadata.cursor?.globs ?? copilotApplyTo;
79
+ const alwaysApply = explicitAlwaysApply ?? (scopedGlobs === undefined || scopedGlobs === '**');
80
+ const globs = alwaysApply ? undefined : scopedGlobs;
81
+ return {
82
+ alwaysApply,
83
+ globs,
84
+ applyTo: copilotApplyTo,
85
+ };
86
+ }
87
+ /**
88
+ * Renders metadata and markdown body content back into a frontmatter document string.
89
+ */
90
+ export function renderMarkdown({ metadata, body, }) {
91
+ const normalizedBody = body.trim();
92
+ if (Object.keys(metadata).length === 0) {
93
+ return `${normalizedBody}\n`;
94
+ }
95
+ return matter.stringify(normalizedBody, metadata);
96
+ }
@@ -0,0 +1,8 @@
1
+ import type { AgentsContext, TargetRoots } from './context.js';
2
+ /**
3
+ * Installs all generated command, rule, and skill outputs into the requested targets.
4
+ */
5
+ export declare function installToTargets(context: AgentsContext, { targetRoots }: {
6
+ targetRoots: TargetRoots;
7
+ }): Promise<void>;
8
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1,380 @@
1
+ import { Chalk } from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import { glob } from 'glob';
4
+ import path from 'node:path';
5
+ import { commandFrontmatterSchema, compactObject, normalizeRuleMetadata, parseFrontmatter, renderMarkdown, ruleFrontmatterSchema, validateFrontmatter, } from './frontmatter.js';
6
+ const ALL_INSTALL_EDITORS = ['copilot', 'cursor'];
7
+ const chalk = new Chalk({ level: 3 });
8
+ /**
9
+ * Installs all generated command, rule, and skill outputs into the requested targets.
10
+ */
11
+ export async function installToTargets(context, { targetRoots }) {
12
+ await ensureTargetDirectories(targetRoots);
13
+ const installItems = [
14
+ ...(await collectCommandInstallItems(context, { targetRoots })),
15
+ ...(await collectRuleInstallItems(context, { targetRoots })),
16
+ ...(await collectSkillInstallItems(context, { targetRoots })),
17
+ ];
18
+ const { installableItems, skippedItems } = collectConflictFilterResult(installItems);
19
+ const appliedItems = [];
20
+ for (const installItem of installableItems) {
21
+ appliedItems.push(await applyInstallItem(installItem));
22
+ }
23
+ console.log(renderInstallReport(appliedItems, skippedItems));
24
+ }
25
+ /**
26
+ * Ensures that all target root directories exist before generated files are written.
27
+ */
28
+ async function ensureTargetDirectories(targetRoots) {
29
+ await Promise.all(Object.values(targetRoots).map((dirPath) => fs.ensureDir(dirPath)));
30
+ }
31
+ /**
32
+ * Returns the markdown source files found directly under a source root.
33
+ */
34
+ async function collectMarkdownFiles(rootDir) {
35
+ await fs.ensureDir(rootDir);
36
+ const matches = await glob([path.join(rootDir, '*.md')]);
37
+ return matches.sort();
38
+ }
39
+ /**
40
+ * Writes one markdown file after rendering its frontmatter and body content.
41
+ */
42
+ async function writeMarkdownFile(filePath, metadata, body) {
43
+ await fs.ensureDir(path.dirname(filePath));
44
+ await fs.writeFile(filePath, renderMarkdown({ metadata, body }), 'utf8');
45
+ }
46
+ /**
47
+ * Detects whether each editor target for one item will be installed or updated.
48
+ */
49
+ async function detectInstallChanges(installItem) {
50
+ return Promise.all(installItem.targets.map(async (target) => ({
51
+ editor: target.editor,
52
+ changeType: (await fs.pathExists(target.outputPath))
53
+ ? 'update'
54
+ : 'install',
55
+ })));
56
+ }
57
+ /**
58
+ * Applies one install item and records the change type for each editor target.
59
+ */
60
+ async function applyInstallItem(installItem) {
61
+ const changes = await detectInstallChanges(installItem);
62
+ await installItem.install();
63
+ return {
64
+ item: installItem,
65
+ changes,
66
+ };
67
+ }
68
+ /**
69
+ * Collects install operations for command sources after validating their frontmatter.
70
+ */
71
+ async function collectCommandInstallItems(context, { targetRoots }) {
72
+ const commandFiles = await collectMarkdownFiles(context.sourceRoots.commands);
73
+ const installItems = [];
74
+ for (const filePath of commandFiles) {
75
+ const fileName = path.basename(filePath, '.md');
76
+ const rawContent = await fs.readFile(filePath, 'utf8');
77
+ const { metadata, body } = parseFrontmatter(rawContent);
78
+ const commandMetadata = validateFrontmatter({
79
+ filePath,
80
+ metadata,
81
+ schema: commandFrontmatterSchema,
82
+ });
83
+ if (!commandMetadata) {
84
+ continue;
85
+ }
86
+ const commandName = commandMetadata.name;
87
+ const description = commandMetadata.description;
88
+ const copilotPromptPath = path.join(targetRoots.copilotPrompts, `${fileName}.prompt.md`);
89
+ const cursorSkillPath = path.join(targetRoots.cursorSkills, commandName, 'SKILL.md');
90
+ const promptMetadata = compactObject({
91
+ name: commandName,
92
+ description,
93
+ });
94
+ const cursorMetadata = compactObject({
95
+ name: commandName,
96
+ description,
97
+ 'disable-model-invocation': commandMetadata.cursor?.['disable-model-invocation'],
98
+ });
99
+ installItems.push({
100
+ kind: 'command',
101
+ editors: ALL_INSTALL_EDITORS,
102
+ name: commandName,
103
+ sourcePath: filePath,
104
+ ownershipKeys: [
105
+ `copilot-prompt-path:${copilotPromptPath}`,
106
+ `cursor-skill-name:${commandName}`,
107
+ ],
108
+ targets: [
109
+ { editor: 'copilot', outputPath: copilotPromptPath },
110
+ { editor: 'cursor', outputPath: cursorSkillPath },
111
+ ],
112
+ install: async () => {
113
+ await writeMarkdownFile(copilotPromptPath, promptMetadata, body);
114
+ await writeMarkdownFile(cursorSkillPath, cursorMetadata, body);
115
+ },
116
+ });
117
+ }
118
+ return installItems;
119
+ }
120
+ /**
121
+ * Collects install operations for rule sources after validating their frontmatter.
122
+ */
123
+ async function collectRuleInstallItems(context, { targetRoots }) {
124
+ const ruleFiles = await collectMarkdownFiles(context.sourceRoots.rules);
125
+ const installItems = [];
126
+ for (const filePath of ruleFiles) {
127
+ const fileName = path.basename(filePath, '.md');
128
+ const rawContent = await fs.readFile(filePath, 'utf8');
129
+ const { metadata, body } = parseFrontmatter(rawContent);
130
+ const ruleMetadata = validateFrontmatter({
131
+ filePath,
132
+ metadata,
133
+ schema: ruleFrontmatterSchema,
134
+ });
135
+ if (!ruleMetadata) {
136
+ continue;
137
+ }
138
+ const normalized = normalizeRuleMetadata(ruleMetadata);
139
+ const copilotInstructionPath = path.join(targetRoots.copilotInstructions, `${fileName}.instructions.md`);
140
+ const cursorRulePath = path.join(targetRoots.cursorRules, `${fileName}.mdc`);
141
+ const copilotMetadata = compactObject({
142
+ description: ruleMetadata.description,
143
+ applyTo: normalized.applyTo,
144
+ });
145
+ const cursorMetadata = compactObject({
146
+ description: ruleMetadata.description,
147
+ globs: normalized.alwaysApply ? undefined : normalized.globs,
148
+ alwaysApply: normalized.alwaysApply,
149
+ });
150
+ installItems.push({
151
+ kind: 'rule',
152
+ editors: ALL_INSTALL_EDITORS,
153
+ name: fileName,
154
+ sourcePath: filePath,
155
+ ownershipKeys: [
156
+ `copilot-instruction-path:${copilotInstructionPath}`,
157
+ `cursor-rule-path:${cursorRulePath}`,
158
+ ],
159
+ targets: [
160
+ { editor: 'copilot', outputPath: copilotInstructionPath },
161
+ { editor: 'cursor', outputPath: cursorRulePath },
162
+ ],
163
+ install: async () => {
164
+ await writeMarkdownFile(copilotInstructionPath, copilotMetadata, body);
165
+ await writeMarkdownFile(cursorRulePath, cursorMetadata, body);
166
+ },
167
+ });
168
+ }
169
+ return installItems;
170
+ }
171
+ /**
172
+ * Collects install operations for local skill directories.
173
+ */
174
+ async function collectSkillInstallItems(context, { targetRoots }) {
175
+ await fs.ensureDir(context.sourceRoots.skills);
176
+ const entries = await fs.readdir(context.sourceRoots.skills, {
177
+ withFileTypes: true,
178
+ });
179
+ const installItems = [];
180
+ for (const entry of entries) {
181
+ if (!entry.isDirectory()) {
182
+ continue;
183
+ }
184
+ const sourceDir = path.join(context.sourceRoots.skills, entry.name);
185
+ const copilotSkillPath = path.join(targetRoots.copilotSkills, entry.name);
186
+ const cursorSkillPath = path.join(targetRoots.cursorSkills, entry.name);
187
+ installItems.push({
188
+ kind: 'skill',
189
+ editors: ALL_INSTALL_EDITORS,
190
+ name: entry.name,
191
+ sourcePath: sourceDir,
192
+ ownershipKeys: [
193
+ `copilot-skill-name:${entry.name}`,
194
+ `cursor-skill-name:${entry.name}`,
195
+ ],
196
+ targets: [
197
+ { editor: 'copilot', outputPath: copilotSkillPath },
198
+ { editor: 'cursor', outputPath: cursorSkillPath },
199
+ ],
200
+ install: async () => {
201
+ await copyDirectoryContents(sourceDir, copilotSkillPath);
202
+ await copyDirectoryContents(sourceDir, cursorSkillPath);
203
+ },
204
+ });
205
+ }
206
+ return installItems;
207
+ }
208
+ /**
209
+ * Copies a skill directory into a target directory after clearing any previous contents.
210
+ */
211
+ async function copyDirectoryContents(sourceDir, targetDir) {
212
+ await fs.emptyDir(targetDir);
213
+ const entryNames = await fs.readdir(sourceDir);
214
+ for (const entryName of entryNames) {
215
+ await fs.copy(path.join(sourceDir, entryName), path.join(targetDir, entryName));
216
+ }
217
+ }
218
+ /**
219
+ * Collects the installable and skipped items after analyzing output namespace conflicts.
220
+ */
221
+ function collectConflictFilterResult(items) {
222
+ const ownershipMap = new Map();
223
+ for (const item of items) {
224
+ for (const ownershipKey of item.ownershipKeys) {
225
+ const existingOwners = ownershipMap.get(ownershipKey);
226
+ if (existingOwners) {
227
+ existingOwners.push(item);
228
+ }
229
+ else {
230
+ ownershipMap.set(ownershipKey, [item]);
231
+ }
232
+ }
233
+ }
234
+ const skippedItemsBySourcePath = new Map();
235
+ for (const [ownershipKey, owners] of ownershipMap) {
236
+ if (owners.length < 2) {
237
+ continue;
238
+ }
239
+ const conflictDescription = describeOwnershipKey(ownershipKey);
240
+ for (const owner of owners) {
241
+ const existingSkippedItem = skippedItemsBySourcePath.get(owner.sourcePath);
242
+ if (existingSkippedItem) {
243
+ existingSkippedItem.conflictDescriptions.push(conflictDescription);
244
+ }
245
+ else {
246
+ skippedItemsBySourcePath.set(owner.sourcePath, {
247
+ item: owner,
248
+ conflictDescriptions: [conflictDescription],
249
+ });
250
+ }
251
+ }
252
+ }
253
+ const skippedItems = [...skippedItemsBySourcePath.values()].map((skippedItem) => ({
254
+ item: skippedItem.item,
255
+ conflictDescriptions: [
256
+ ...new Set(skippedItem.conflictDescriptions),
257
+ ].sort(),
258
+ }));
259
+ const skippedSourcePaths = new Set(skippedItems.map((skippedItem) => skippedItem.item.sourcePath));
260
+ return {
261
+ installableItems: items.filter((item) => !skippedSourcePaths.has(item.sourcePath)),
262
+ skippedItems,
263
+ };
264
+ }
265
+ /**
266
+ * Renders an install summary grouped by editor, item kind, and skipped conflicts.
267
+ */
268
+ function renderInstallReport(appliedItems, skippedItems) {
269
+ const sections = [chalk.bold.cyan('Applied changes:')];
270
+ const copilotInstalledItems = collectEditorAppliedInstallItems(appliedItems, 'copilot');
271
+ const cursorInstalledItems = collectEditorAppliedInstallItems(appliedItems, 'cursor');
272
+ sections.push(renderEditorInstallSection('Copilot', copilotInstalledItems));
273
+ sections.push(renderEditorInstallSection('Cursor', cursorInstalledItems));
274
+ if (skippedItems.length === 0) {
275
+ sections.push(`${chalk.bold.green('Skipped conflicts:')} ${chalk.green('None')}`);
276
+ }
277
+ else {
278
+ const skippedLines = skippedItems
279
+ .slice()
280
+ .sort((left, right) => formatInstallItemLabel(left.item).localeCompare(formatInstallItemLabel(right.item)))
281
+ .map((skippedItem) => [
282
+ `- ${chalk.red(formatInstallItemLabel(skippedItem.item))}`,
283
+ ` * ${chalk.bold.red('due to:')} ${chalk.yellow(skippedItem.conflictDescriptions.join(', '))}`,
284
+ ].join('\n'));
285
+ sections.push(`${chalk.bold.red('Skipped conflicts:')}\n${skippedLines.join('\n')}`);
286
+ }
287
+ return sections.join('\n\n');
288
+ }
289
+ /**
290
+ * Collects the applied install items relevant to one editor.
291
+ */
292
+ function collectEditorAppliedInstallItems(appliedItems, editor) {
293
+ return appliedItems.flatMap((appliedItem) => appliedItem.changes
294
+ .filter((change) => change.editor === editor)
295
+ .map((change) => ({
296
+ item: appliedItem.item,
297
+ changeType: change.changeType,
298
+ })));
299
+ }
300
+ /**
301
+ * Renders the installed items for one editor grouped by item kind.
302
+ */
303
+ function renderEditorInstallSection(editorLabel, installedItems) {
304
+ const kindSections = [
305
+ renderKindInstallLine('commands', 'command', installedItems),
306
+ renderKindInstallLine('rules', 'rule', installedItems),
307
+ renderKindInstallLine('skills', 'skill', installedItems),
308
+ ].filter((section) => section !== undefined);
309
+ return [`- ${colorEditorLabel(editorLabel)}`, ...kindSections].join('\n');
310
+ }
311
+ /**
312
+ * Renders one install summary section for a specific item kind.
313
+ */
314
+ function renderKindInstallLine(label, kind, installedItems) {
315
+ const matchingItems = installedItems
316
+ .filter((item) => item.item.kind === kind)
317
+ .slice()
318
+ .sort((left, right) => left.item.name.localeCompare(right.item.name));
319
+ if (matchingItems.length === 0) {
320
+ return undefined;
321
+ }
322
+ return [
323
+ ` * ${colorKindLabel(label)}`,
324
+ ...matchingItems.map(renderAppliedInstallItemLine),
325
+ ].join('\n');
326
+ }
327
+ /**
328
+ * Returns the styled editor label used in the install summary.
329
+ */
330
+ function colorEditorLabel(editorLabel) {
331
+ return chalk.bold.blue(editorLabel);
332
+ }
333
+ /**
334
+ * Returns the styled item-kind label used in the install summary.
335
+ */
336
+ function colorKindLabel(label) {
337
+ return chalk.bold.yellow(label);
338
+ }
339
+ /**
340
+ * Returns the styled change-type label used in the install summary.
341
+ */
342
+ function colorChangeType(changeType) {
343
+ if (changeType === 'install') {
344
+ return chalk.green(changeType);
345
+ }
346
+ return chalk.yellow(changeType);
347
+ }
348
+ /**
349
+ * Renders one styled applied-item line in the install summary.
350
+ */
351
+ function renderAppliedInstallItemLine(matchingItem) {
352
+ return ` - ${chalk.whiteBright(matchingItem.item.name)} (${colorChangeType(matchingItem.changeType)})`;
353
+ }
354
+ /**
355
+ * Returns a readable label for one install item in conflict warnings.
356
+ */
357
+ function formatInstallItemLabel(item) {
358
+ return `${item.kind} "${item.name}" from ${item.sourcePath}`;
359
+ }
360
+ /**
361
+ * Converts an internal ownership key into a warning message fragment.
362
+ */
363
+ function describeOwnershipKey(ownershipKey) {
364
+ if (ownershipKey.startsWith('cursor-skill-name:')) {
365
+ return `Cursor skill name "${ownershipKey.slice('cursor-skill-name:'.length)}"`;
366
+ }
367
+ if (ownershipKey.startsWith('copilot-skill-name:')) {
368
+ return `Copilot skill name "${ownershipKey.slice('copilot-skill-name:'.length)}"`;
369
+ }
370
+ if (ownershipKey.startsWith('copilot-prompt-path:')) {
371
+ return `Copilot prompt output "${ownershipKey.slice('copilot-prompt-path:'.length)}"`;
372
+ }
373
+ if (ownershipKey.startsWith('copilot-instruction-path:')) {
374
+ return `Copilot instruction output "${ownershipKey.slice('copilot-instruction-path:'.length)}"`;
375
+ }
376
+ if (ownershipKey.startsWith('cursor-rule-path:')) {
377
+ return `Cursor rule output "${ownershipKey.slice('cursor-rule-path:'.length)}"`;
378
+ }
379
+ return `output namespace "${ownershipKey}"`;
380
+ }