@lidtop/loadout 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.
@@ -0,0 +1,80 @@
1
+ import { validAnswer, } from './schema.js';
2
+ export function resolveKits(catalog, selected) {
3
+ const done = new Set();
4
+ const visiting = [];
5
+ const visit = (id) => {
6
+ if (done.has(id))
7
+ return;
8
+ if (visiting.includes(id))
9
+ throw new Error(`Dependency cycle: ${[...visiting, id].join(' -> ')}`);
10
+ const kit = catalog.kits.get(id);
11
+ if (!kit)
12
+ throw new Error(`Unknown kit: ${id}${visiting.length ? ` (required by ${visiting.at(-1)})` : '. Remove it with loadout disable if it was deleted from the catalog.'}`);
13
+ if (kit.ready === false)
14
+ throw new Error(`${id} needs setup before it can be selected. Edit its kit.yaml and set ready: true.`);
15
+ visiting.push(id);
16
+ for (const required of [...kit.requires].sort())
17
+ visit(required);
18
+ visiting.pop();
19
+ done.add(id);
20
+ };
21
+ for (const id of [...new Set(selected)].sort())
22
+ visit(id);
23
+ return [...done];
24
+ }
25
+ export function reasons(catalog, selected, target) {
26
+ return [...new Set(selected)]
27
+ .sort()
28
+ .filter((id) => id !== target && resolveKits(catalog, [id]).includes(target));
29
+ }
30
+ export function disableKits(catalog, selected, target, cascade) {
31
+ // A removed catalog kit can still be deselected to recover saved state.
32
+ const dependents = reasons(catalog, selected.filter((id) => catalog.kits.has(id)), target);
33
+ if (dependents.length && !cascade)
34
+ throw new Error(`${target} is required by: ${dependents.join(', ')}. Use --cascade to disable those selections too.`);
35
+ return selected.filter((id) => id !== target && (!cascade || !dependents.includes(id)));
36
+ }
37
+ export async function configure(catalog, state, ask) {
38
+ const next = structuredClone(state);
39
+ next.selected = [...new Set(next.selected)].sort();
40
+ for (const id of resolveKits(catalog, next.selected)) {
41
+ for (const [key, question] of Object.entries(catalog.kits.get(id).questions).sort(([a], [b]) => a.localeCompare(b))) {
42
+ if (!Object.hasOwn(next.answers, id))
43
+ next.answers[id] = {};
44
+ const saved = Object.hasOwn(next.answers[id], key)
45
+ ? next.answers[id][key]
46
+ : undefined;
47
+ let answer = saved ?? question.default;
48
+ if (ask)
49
+ answer = await ask(id, key, question, validAnswer(question, answer) ? answer : undefined);
50
+ if (!validAnswer(question, answer))
51
+ throw new Error(`${id}.${key}: ${answer === undefined ? 'missing required answer' : 'invalid saved answer'}. Run loadout or pass --answer ${id}.${key}=VALUE.`);
52
+ next.answers[id][key] = answer;
53
+ }
54
+ }
55
+ return next;
56
+ }
57
+ export function setAnswers(catalog, state, values) {
58
+ for (const value of values) {
59
+ const match = /^([a-z0-9-]+)\.([a-z0-9-]+)=(.*)$/.exec(value);
60
+ if (!match)
61
+ throw new Error(`Invalid answer ${value}; expected kit.question=value`);
62
+ const id = match[1], key = match[2], raw = match[3];
63
+ const questions = catalog.kits.get(id)?.questions;
64
+ const q = questions && Object.hasOwn(questions, key) ? questions[key] : undefined;
65
+ if (!q)
66
+ throw new Error(`Unknown question: ${id}.${key}`);
67
+ const answer = q.type === 'boolean'
68
+ ? raw === 'true'
69
+ ? true
70
+ : raw === 'false'
71
+ ? false
72
+ : raw
73
+ : raw;
74
+ if (!validAnswer(q, answer))
75
+ throw new Error(`Invalid answer for ${id}.${key}: ${raw}`);
76
+ if (!Object.hasOwn(state.answers, id))
77
+ state.answers[id] = {};
78
+ state.answers[id][key] = answer;
79
+ }
80
+ }
@@ -0,0 +1,4 @@
1
+ export declare class DownloadCancelledError extends Error {
2
+ constructor();
3
+ }
4
+ export declare function retryDownload<T>(attempt: () => Promise<T>, retry?: (error: Error) => Promise<boolean>, onRetry?: () => void): Promise<T>;
package/dist/retry.js ADDED
@@ -0,0 +1,26 @@
1
+ export class DownloadCancelledError extends Error {
2
+ constructor() {
3
+ super('Cancelled. No kit selections or agent outputs saved.');
4
+ this.name = 'DownloadCancelledError';
5
+ }
6
+ }
7
+ export async function retryDownload(attempt, retry, onRetry) {
8
+ for (;;) {
9
+ for (let tries = 0; tries < 2; tries++) {
10
+ try {
11
+ return await attempt();
12
+ }
13
+ catch (failure) {
14
+ const error = failure instanceof Error ? failure : new Error(String(failure));
15
+ if (tries === 0) {
16
+ onRetry?.();
17
+ continue;
18
+ }
19
+ if (!retry)
20
+ throw error;
21
+ if (!(await retry(error)))
22
+ throw new DownloadCancelledError();
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,111 @@
1
+ import { z } from 'zod';
2
+ export declare const idSchema: z.ZodString;
3
+ export declare const agents: readonly ['codex', 'claude'];
4
+ export declare const relativePath: z.ZodString;
5
+ export declare const scopeSchema: z.ZodString;
6
+ declare const question: z.ZodDiscriminatedUnion<[z.ZodObject<{
7
+ type: z.ZodLiteral<"boolean">;
8
+ message: z.ZodString;
9
+ default: z.ZodOptional<z.ZodBoolean>;
10
+ }, z.core.$strict>, z.ZodObject<{
11
+ type: z.ZodLiteral<"choice">;
12
+ message: z.ZodString;
13
+ choices: z.ZodArray<z.ZodString>;
14
+ default: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strict>], "type">;
16
+ export declare const kitSchema: z.ZodObject<{
17
+ schemaVersion: z.ZodLiteral<1>;
18
+ id: z.ZodString;
19
+ description: z.ZodString;
20
+ ready: z.ZodOptional<z.ZodBoolean>;
21
+ requires: z.ZodDefault<z.ZodArray<z.ZodString>>;
22
+ questions: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
23
+ type: z.ZodLiteral<"boolean">;
24
+ message: z.ZodString;
25
+ default: z.ZodOptional<z.ZodBoolean>;
26
+ }, z.core.$strict>, z.ZodObject<{
27
+ type: z.ZodLiteral<"choice">;
28
+ message: z.ZodString;
29
+ choices: z.ZodArray<z.ZodString>;
30
+ default: z.ZodOptional<z.ZodString>;
31
+ }, z.core.$strict>], "type">>>;
32
+ outputs: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
33
+ type: z.ZodLiteral<"instructions">;
34
+ source: z.ZodString;
35
+ scope: z.ZodDefault<z.ZodString>;
36
+ when: z.ZodOptional<z.ZodObject<{
37
+ answer: z.ZodString;
38
+ equals: z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>;
39
+ }, z.core.$strict>>;
40
+ }, z.core.$strict>, z.ZodObject<{
41
+ type: z.ZodLiteral<"skill">;
42
+ source: z.ZodString;
43
+ when: z.ZodOptional<z.ZodObject<{
44
+ answer: z.ZodString;
45
+ equals: z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>;
46
+ }, z.core.$strict>>;
47
+ }, z.core.$strict>], "type">>>;
48
+ }, z.core.$strict>;
49
+ export declare const externalSourceSchema: z.ZodObject<{
50
+ repo: z.ZodString;
51
+ ref: z.ZodString;
52
+ skills: z.ZodArray<z.ZodString>;
53
+ license: z.ZodDefault<z.ZodString>;
54
+ }, z.core.$strict>;
55
+ export declare const externalKitSchema: z.ZodObject<{
56
+ id: z.ZodString;
57
+ description: z.ZodString;
58
+ source: z.ZodObject<{
59
+ repo: z.ZodString;
60
+ ref: z.ZodString;
61
+ skills: z.ZodArray<z.ZodString>;
62
+ license: z.ZodDefault<z.ZodString>;
63
+ }, z.core.$strict>;
64
+ }, z.core.$strict>;
65
+ export type ExternalSource = z.infer<typeof externalSourceSchema>;
66
+ export type ExternalKit = z.infer<typeof externalKitSchema>;
67
+ export declare const configSchema: z.ZodObject<{
68
+ schemaVersion: z.ZodLiteral<1>;
69
+ curated: z.ZodDefault<z.ZodBoolean>;
70
+ externalKits: z.ZodDefault<z.ZodArray<z.ZodObject<{
71
+ id: z.ZodString;
72
+ description: z.ZodString;
73
+ source: z.ZodObject<{
74
+ repo: z.ZodString;
75
+ ref: z.ZodString;
76
+ skills: z.ZodArray<z.ZodString>;
77
+ license: z.ZodDefault<z.ZodString>;
78
+ }, z.core.$strict>;
79
+ }, z.core.$strict>>>;
80
+ }, z.core.$strict>;
81
+ export declare const stateSchema: z.ZodObject<{
82
+ schemaVersion: z.ZodLiteral<1>;
83
+ selected: z.ZodArray<z.ZodString>;
84
+ answers: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>>;
85
+ }, z.core.$strict>;
86
+ export declare const ownedSchema: z.ZodObject<{
87
+ schemaVersion: z.ZodLiteral<1>;
88
+ files: z.ZodRecord<z.ZodString, z.ZodObject<{
89
+ hash: z.ZodString;
90
+ mode: z.ZodUnion<readonly [z.ZodLiteral<420>, z.ZodLiteral<493>]>;
91
+ }, z.core.$strict>>;
92
+ }, z.core.$strict>;
93
+ export type Question = z.infer<typeof question>;
94
+ export type Answer = boolean | string;
95
+ export type State = z.infer<typeof stateSchema>;
96
+ export type Kit = z.infer<typeof kitSchema> & {
97
+ directory: string;
98
+ external?: ExternalSource;
99
+ pinned?: ExternalSource;
100
+ origin?: 'curated' | 'external' | 'bundled';
101
+ };
102
+ export type Catalog = {
103
+ root: string;
104
+ kits: Map<string, Kit>;
105
+ global?: boolean;
106
+ };
107
+ export declare function validAnswer(q: Question, value: unknown): value is Answer;
108
+ export declare function parse<T>(schema: z.ZodType<T>, value: unknown, label: string): T;
109
+ export declare function kitSource(kit: Kit): string;
110
+ export declare function sameSource(a: ExternalSource, b: ExternalSource): boolean;
111
+ export {};
package/dist/schema.js ADDED
@@ -0,0 +1,139 @@
1
+ import { z } from 'zod';
2
+ export const idSchema = z
3
+ .string()
4
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase words separated by hyphens');
5
+ export const agents = ['codex', 'claude'];
6
+ export const relativePath = z
7
+ .string()
8
+ .min(1)
9
+ .refine((p) => p === '.' ||
10
+ (!p.startsWith('/') &&
11
+ !/[\\:\x00-\x1f*?\[\]!#]/.test(p) &&
12
+ p
13
+ .split('/')
14
+ .every((s) => s !== '..' && s !== '.' && s !== '' && !s.endsWith(' '))), 'Use a relative path without traversal, glob characters, or backslashes');
15
+ export const scopeSchema = relativePath.refine((p) => !p
16
+ .split('/')
17
+ .some((s) => ['.git', '.loadout', '.agents', '.claude', '.codex'].includes(s)), 'Scope must be a repository directory outside configuration directories');
18
+ const condition = z
19
+ .object({ answer: idSchema, equals: z.union([z.boolean(), z.string()]) })
20
+ .strict();
21
+ const question = z.discriminatedUnion('type', [
22
+ z
23
+ .object({
24
+ type: z.literal('boolean'),
25
+ message: z.string().min(1),
26
+ default: z.boolean().optional(),
27
+ })
28
+ .strict(),
29
+ z
30
+ .object({
31
+ type: z.literal('choice'),
32
+ message: z.string().min(1),
33
+ choices: z.array(z.string().min(1)).min(1),
34
+ default: z.string().optional(),
35
+ })
36
+ .strict(),
37
+ ]);
38
+ const output = z.discriminatedUnion('type', [
39
+ z
40
+ .object({
41
+ type: z.literal('instructions'),
42
+ source: relativePath,
43
+ scope: scopeSchema.default('.'),
44
+ when: condition.optional(),
45
+ })
46
+ .strict(),
47
+ z
48
+ .object({
49
+ type: z.literal('skill'),
50
+ source: relativePath,
51
+ when: condition.optional(),
52
+ })
53
+ .strict(),
54
+ ]);
55
+ export const kitSchema = z
56
+ .object({
57
+ schemaVersion: z.literal(1),
58
+ id: idSchema,
59
+ description: z.string().min(1),
60
+ ready: z.boolean().optional(),
61
+ requires: z.array(idSchema).default([]),
62
+ questions: z.record(idSchema, question).default({}),
63
+ outputs: z.array(output).default([]),
64
+ })
65
+ .strict()
66
+ .refine((kit) => kit.ready === false || kit.outputs.length > 0, {
67
+ message: 'Ready kits need at least one output',
68
+ path: ['outputs'],
69
+ });
70
+ export const externalSourceSchema = z
71
+ .object({
72
+ repo: z
73
+ .string()
74
+ .regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/, 'Use owner/repository'),
75
+ ref: z
76
+ .string()
77
+ .regex(/^[a-f0-9]{40}$/, 'Use a full 40-character Git commit SHA'),
78
+ skills: z
79
+ .array(relativePath.refine((p) => p !== '.' && idSchema.safeParse(p.split('/').at(-1)).success, 'Use a skill directory with a lowercase name'))
80
+ .min(1)
81
+ .max(20),
82
+ license: relativePath.default('LICENSE'),
83
+ })
84
+ .strict();
85
+ export const externalKitSchema = z
86
+ .object({
87
+ id: idSchema,
88
+ description: z.string().min(1),
89
+ source: externalSourceSchema,
90
+ })
91
+ .strict();
92
+ export const configSchema = z
93
+ .object({
94
+ schemaVersion: z.literal(1),
95
+ curated: z.boolean().default(true),
96
+ externalKits: z.array(externalKitSchema).default([]),
97
+ })
98
+ .strict();
99
+ export const stateSchema = z
100
+ .object({
101
+ schemaVersion: z.literal(1),
102
+ selected: z.array(idSchema),
103
+ answers: z.record(idSchema, z.record(idSchema, z.union([z.boolean(), z.string()]))),
104
+ })
105
+ .strict();
106
+ export const ownedSchema = z
107
+ .object({
108
+ schemaVersion: z.literal(1),
109
+ files: z.record(z.string(), z
110
+ .object({
111
+ hash: z.string().regex(/^[a-f0-9]{64}$/),
112
+ mode: z.union([z.literal(420), z.literal(493)]),
113
+ })
114
+ .strict()),
115
+ })
116
+ .strict();
117
+ export function validAnswer(q, value) {
118
+ return q.type === 'boolean'
119
+ ? typeof value === 'boolean'
120
+ : typeof value === 'string' && q.choices.includes(value);
121
+ }
122
+ export function parse(schema, value, label) {
123
+ const result = schema.safeParse(value);
124
+ if (!result.success)
125
+ throw new Error(`${label}: ${result.error.issues.map((i) => `${i.path.join('.') || 'value'}: ${i.message}`).join('; ')}`);
126
+ return result.data;
127
+ }
128
+ export function kitSource(kit) {
129
+ return kit.origin === 'bundled'
130
+ ? 'loadout'
131
+ : ((kit.pinned ?? kit.external)?.repo ?? 'Repository');
132
+ }
133
+ export function sameSource(a, b) {
134
+ return (a.repo === b.repo &&
135
+ a.ref === b.ref &&
136
+ a.license === b.license &&
137
+ JSON.stringify([...a.skills].sort()) ===
138
+ JSON.stringify([...b.skills].sort()));
139
+ }
@@ -0,0 +1,27 @@
1
+ import { type Catalog, type State } from './schema.js';
2
+ import { type FileContent, type Rendered } from './render.js';
3
+ export type Change = {
4
+ path: string;
5
+ before?: FileContent;
6
+ after?: FileContent;
7
+ kind: 'create' | 'update' | 'delete' | 'unchanged';
8
+ };
9
+ export type Plan = {
10
+ root: string;
11
+ changes: Change[];
12
+ adopted?: string[];
13
+ guard?: {
14
+ untracked: string[];
15
+ directories: {
16
+ path: string;
17
+ files: string[];
18
+ }[];
19
+ };
20
+ };
21
+ export declare function loadState(catalog: Catalog): State;
22
+ export declare function ignoredText(original: string, paths: string[]): string;
23
+ export declare function plan(catalog: Catalog, state: State, rendered: Rendered, options?: {
24
+ adopt?: boolean;
25
+ }): Plan;
26
+ export declare function apply(plan: Plan): number;
27
+ export declare function applyAll(plans: Plan[]): number;
@@ -0,0 +1,298 @@
1
+ import fs from 'node:fs';
2
+ import { isOutput } from './output-path.js';
3
+ import { prepareAdoption } from './adoption.js';
4
+ import path from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { exists, json, readOptional, safePath, walk, portableMode, } from './fs.js';
8
+ import { parse, ownedSchema, stateSchema, } from './schema.js';
9
+ const start = '# >>> loadout';
10
+ const end = '# <<< loadout';
11
+ const hash = (content) => createHash('sha256').update(content).digest('hex');
12
+ export function loadState(catalog) {
13
+ const raw = readOptional(catalog.root, '.loadout/local.json');
14
+ return raw
15
+ ? parse(stateSchema, JSON.parse(raw.toString()), '.loadout/local.json')
16
+ : { schemaVersion: 1, selected: [], answers: {} };
17
+ }
18
+ function snapshot(root, relative) {
19
+ const content = readOptional(root, relative);
20
+ return content === undefined
21
+ ? undefined
22
+ : {
23
+ content,
24
+ mode: portableMode(fs.statSync(safePath(root, relative)).mode),
25
+ };
26
+ }
27
+ function equal(a, b) {
28
+ return a === undefined || b === undefined
29
+ ? a === b
30
+ : a.mode === b.mode && a.content.equals(b.content);
31
+ }
32
+ export function ignoredText(original, paths) {
33
+ const lines = original.split(/\r?\n/);
34
+ const first = lines.indexOf(start), last = lines.indexOf(end);
35
+ if (first < 0 !== last < 0 ||
36
+ last < first ||
37
+ lines.filter((l) => l === start).length > 1 ||
38
+ lines.filter((l) => l === end).length > 1)
39
+ throw new Error('Malformed Loadout block in .gitignore; repair its markers before applying.');
40
+ if (first >= 0)
41
+ lines.splice(first, last - first + 1);
42
+ const base = lines.join('\n').replace(/\n*$/, '');
43
+ // Escape gitignore metacharacters so these patterns own only exact paths.
44
+ const escape = (p) => p.replace(/[\\*?\[\]#! ]/g, '\\$&');
45
+ return `${base ? `${base}\n\n` : ''}${start}\n${[
46
+ ...new Set([
47
+ '.loadout/local.json',
48
+ '.loadout/generated.json',
49
+ '.loadout/external.json',
50
+ '.loadout/adopted.json',
51
+ '.loadout/adopted/',
52
+ '.loadout/apply.lock/',
53
+ ...paths,
54
+ ]),
55
+ ]
56
+ .sort()
57
+ .map((p) => `/${escape(p)}`)
58
+ .join('\n')}\n${end}\n`;
59
+ }
60
+ function trackedFiles(root) {
61
+ try {
62
+ return new Set(execFileSync('git', ['-C', root, 'ls-files', '-z'], {
63
+ encoding: 'utf8',
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ })
66
+ .split('\0')
67
+ .filter(Boolean));
68
+ }
69
+ catch (error) {
70
+ const e = error;
71
+ if (e.code === 'ENOENT' ||
72
+ e.stderr?.toString().includes('not a git repository'))
73
+ return new Set();
74
+ throw new Error(`Cannot check Git-tracked files: ${e.message}`);
75
+ }
76
+ }
77
+ export function plan(catalog, state, rendered, options = {}) {
78
+ const root = catalog.root;
79
+ const raw = readOptional(root, '.loadout/generated.json');
80
+ const owned = raw
81
+ ? parse(ownedSchema, JSON.parse(raw.toString()), '.loadout/generated.json')
82
+ .files
83
+ : {};
84
+ const adoption = prepareAdoption(root, rendered, owned, !!catalog.global, !!options.adopt);
85
+ rendered = { ...rendered, files: adoption.files };
86
+ const paths = [
87
+ ...new Set([...Object.keys(owned), ...rendered.files.keys()]),
88
+ ].sort();
89
+ const tracked = trackedFiles(root);
90
+ for (const local of [
91
+ '.loadout/local.json',
92
+ '.loadout/generated.json',
93
+ '.loadout/external.json',
94
+ '.loadout/adopted.json',
95
+ ...adoption.beforeOutputs.map((change) => change.path),
96
+ ...adoption.afterOutputs.map((change) => change.path),
97
+ ])
98
+ if (tracked.has(local))
99
+ throw new Error(`${local} is tracked by Git. Untrack personal state before applying.`);
100
+ const changes = [...adoption.beforeOutputs];
101
+ for (const relative of paths) {
102
+ if (!isOutput(relative, catalog.global))
103
+ throw new Error(`Invalid generated ownership path: ${relative}`);
104
+ if (tracked.has(relative))
105
+ throw new Error(`Refusing to manage Git-tracked file: ${relative}. Move its content into a kit and untrack it first.`);
106
+ const before = snapshot(root, relative), after = rendered.files.get(relative), previous = owned[relative];
107
+ if (before &&
108
+ !previous &&
109
+ adoption.adopted.includes(relative) &&
110
+ !equal(before, adoption.originals.get(relative)))
111
+ throw new Error(`File changed while preparing adoption: ${relative}`);
112
+ if (before && !previous && !adoption.adopted.includes(relative))
113
+ throw new Error(`Unmanaged file already exists: ${relative}. Run loadout to preserve existing content, or preview with --adopt --dry-run.`);
114
+ if (before &&
115
+ previous &&
116
+ (hash(before.content) !== previous.hash || before.mode !== previous.mode))
117
+ throw new Error(`Generated file was manually modified: ${relative}. Move your edits into a kit, then restore or remove the generated file before applying.`);
118
+ changes.push({
119
+ path: relative,
120
+ before,
121
+ after,
122
+ kind: equal(before, after)
123
+ ? 'unchanged'
124
+ : !before
125
+ ? 'create'
126
+ : !after
127
+ ? 'delete'
128
+ : 'update',
129
+ });
130
+ }
131
+ for (const skill of rendered.skillRoots) {
132
+ if (exists(safePath(root, skill)) &&
133
+ ![...Object.keys(owned), ...adoption.adopted].some((p) => p.startsWith(`${skill}/`)))
134
+ throw new Error(`Unmanaged skill directory already exists: ${skill}`);
135
+ }
136
+ const files = Object.fromEntries([...rendered.files]
137
+ .filter(([p]) => !adoption.released.has(p))
138
+ .map(([p, f]) => [p, { hash: hash(f.content), mode: f.mode }]));
139
+ const metadata = new Map([
140
+ ['.loadout/local.json', json(state)],
141
+ ['.loadout/generated.json', json({ schemaVersion: 1, files })],
142
+ [
143
+ '.gitignore',
144
+ Buffer.from(ignoredText(readOptional(root, '.gitignore')?.toString() ?? '', [
145
+ ...Object.keys(files),
146
+ ])),
147
+ ],
148
+ ]);
149
+ if (rendered.external) {
150
+ const current = readOptional(root, '.loadout/external.json');
151
+ if (current === undefined
152
+ ? rendered.external.before !== undefined
153
+ : !rendered.external.before?.equals(current))
154
+ throw new Error('External snapshots changed while preparing the preview. Run the command again.');
155
+ metadata.set('.loadout/external.json', rendered.external.content);
156
+ }
157
+ changes.push(...adoption.afterOutputs);
158
+ for (const [relative, content] of metadata) {
159
+ const before = snapshot(root, relative), after = { content, mode: before?.mode ?? 0o644 };
160
+ changes.push({
161
+ path: relative,
162
+ before,
163
+ after,
164
+ kind: equal(before, after) ? 'unchanged' : before ? 'update' : 'create',
165
+ });
166
+ }
167
+ return {
168
+ root,
169
+ changes,
170
+ adopted: adoption.adopted,
171
+ guard: {
172
+ untracked: changes
173
+ .filter((change) => change.path !== '.gitignore')
174
+ .map((change) => change.path),
175
+ directories: [...rendered.skillRoots]
176
+ .filter((skill) => adoption.adopted.some((file) => file.startsWith(`${skill}/`)))
177
+ .map((skill) => ({
178
+ path: skill,
179
+ files: [...rendered.files.keys()]
180
+ .filter((file) => file.startsWith(`${skill}/`))
181
+ .map((file) => file.slice(skill.length + 1))
182
+ .sort(),
183
+ })),
184
+ },
185
+ };
186
+ }
187
+ function writeAtomic(root, relative, file) {
188
+ const target = safePath(root, relative);
189
+ fs.mkdirSync(path.dirname(target), { recursive: true });
190
+ const temp = path.join(path.dirname(target), `.loadout-${randomUUID()}.tmp`);
191
+ try {
192
+ fs.writeFileSync(temp, file.content, { flag: 'wx', mode: file.mode });
193
+ fs.chmodSync(temp, file.mode);
194
+ fs.renameSync(temp, target);
195
+ }
196
+ finally {
197
+ if (exists(temp))
198
+ fs.unlinkSync(temp);
199
+ }
200
+ }
201
+ function prune(root, relative) {
202
+ // Repository scopes and agent configuration roots may predate Loadout.
203
+ // Only prune empty skill directories and internal adoption backups.
204
+ const boundary = /^(\.(?:agents|claude)\/skills)\//.exec(relative)?.[1] ??
205
+ (relative.startsWith('.loadout/adopted/') ? '.loadout' : undefined);
206
+ if (!boundary)
207
+ return;
208
+ const stop = safePath(root, boundary);
209
+ let directory = path.dirname(safePath(root, relative));
210
+ while (directory !== stop) {
211
+ try {
212
+ fs.rmdirSync(directory);
213
+ }
214
+ catch {
215
+ break;
216
+ }
217
+ directory = path.dirname(directory);
218
+ }
219
+ }
220
+ export function apply(plan) {
221
+ return applyAll([plan]);
222
+ }
223
+ export function applyAll(plans) {
224
+ if (new Set(plans.map((plan) => plan.root)).size !== plans.length)
225
+ throw new Error('Cannot apply multiple plans for the same location.');
226
+ const locks = [];
227
+ const written = [];
228
+ try {
229
+ for (const plan of [...plans].sort((a, b) => a.root.localeCompare(b.root))) {
230
+ const lock = safePath(plan.root, '.loadout/apply.lock');
231
+ try {
232
+ fs.mkdirSync(lock);
233
+ }
234
+ catch (error) {
235
+ if (error.code === 'EEXIST')
236
+ throw new Error('Another apply is running (or a previous process stopped). Remove .loadout/apply.lock only after confirming no Loadout process is running.');
237
+ throw error;
238
+ }
239
+ locks.push(lock);
240
+ }
241
+ // Recheck every location before writing to any of them.
242
+ for (const plan of plans) {
243
+ if (!plan.guard)
244
+ continue;
245
+ const tracked = trackedFiles(plan.root);
246
+ for (const file of plan.guard.untracked)
247
+ if (tracked.has(file))
248
+ throw new Error(`File became Git-tracked since preview: ${file}`);
249
+ for (const directory of plan.guard.directories) {
250
+ const current = walk(safePath(plan.root, directory.path)).sort();
251
+ if (JSON.stringify(current) !== JSON.stringify(directory.files))
252
+ throw new Error(`Skill directory changed since preview: ${directory.path}`);
253
+ }
254
+ }
255
+ for (const plan of plans)
256
+ for (const change of plan.changes)
257
+ if (!equal(snapshot(plan.root, change.path), change.before))
258
+ throw new Error(`File changed since preview: ${change.path}. Run the command again.`);
259
+ for (const plan of plans) {
260
+ for (const change of plan.changes) {
261
+ if (change.kind === 'unchanged')
262
+ continue;
263
+ if (change.after)
264
+ writeAtomic(plan.root, change.path, change.after);
265
+ else
266
+ fs.unlinkSync(safePath(plan.root, change.path));
267
+ written.push({ root: plan.root, change });
268
+ }
269
+ }
270
+ }
271
+ catch (error) {
272
+ const failures = [];
273
+ for (const { root, change } of written.reverse()) {
274
+ try {
275
+ if (change.before)
276
+ writeAtomic(root, change.path, change.before);
277
+ else {
278
+ fs.unlinkSync(safePath(root, change.path));
279
+ prune(root, change.path);
280
+ }
281
+ }
282
+ catch {
283
+ failures.push(path.join(root, change.path));
284
+ }
285
+ }
286
+ if (failures.length)
287
+ throw new Error(`${error.message}; rollback failed for: ${failures.join(', ')}`);
288
+ throw error;
289
+ }
290
+ finally {
291
+ for (const lock of locks.reverse())
292
+ fs.rmdirSync(lock);
293
+ }
294
+ for (const { root, change } of written)
295
+ if (!change.after)
296
+ prune(root, change.path);
297
+ return written.length;
298
+ }