@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,285 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { parse as yaml } from 'yaml';
4
+ import { z } from 'zod';
5
+ import { agents, externalSourceSchema, idSchema, relativePath, parse, sameSource, } from './schema.js';
6
+ import { json, readOptional, portableMode } from './fs.js';
7
+ import { resolveKits } from './resolve.js';
8
+ import { render } from './render.js';
9
+ import { retryDownload } from './retry.js';
10
+ const MAX_FILE = 2 * 1024 * 1024;
11
+ const MAX_KIT = 8 * 1024 * 1024;
12
+ const MAX_FILES = 200;
13
+ const snapshotFile = z
14
+ .object({
15
+ data: z.string().max(Math.ceil(MAX_FILE / 3) * 4),
16
+ sha: z.string().regex(/^[a-f0-9]{40}$/),
17
+ mode: z.union([z.literal(420), z.literal(493)]),
18
+ })
19
+ .strict();
20
+ const snapshotSchema = z
21
+ .object({
22
+ integrity: z.string().regex(/^[a-f0-9]{64}$/),
23
+ source: externalSourceSchema,
24
+ files: z.record(relativePath, snapshotFile),
25
+ })
26
+ .strict();
27
+ const storeSchema = z
28
+ .object({
29
+ schemaVersion: z.literal(1),
30
+ kits: z.record(idSchema, snapshotSchema),
31
+ })
32
+ .strict();
33
+ export function readExternal(root) {
34
+ const raw = readOptional(root, '.loadout/external.json');
35
+ return {
36
+ raw,
37
+ store: raw
38
+ ? parse(storeSchema, JSON.parse(raw.toString()), '.loadout/external.json')
39
+ : { schemaVersion: 1, kits: {} },
40
+ };
41
+ }
42
+ function blobHash(content) {
43
+ return createHash('sha1')
44
+ .update(`blob ${content.length}\0`)
45
+ .update(content)
46
+ .digest('hex');
47
+ }
48
+ export const fetchBytes = async (url, limit, signal) => {
49
+ const response = await fetch(url, {
50
+ headers: { 'User-Agent': 'loadout', Accept: 'application/vnd.github+json' },
51
+ redirect: 'error',
52
+ signal: signal
53
+ ? AbortSignal.any([signal, AbortSignal.timeout(30_000)])
54
+ : AbortSignal.timeout(30_000),
55
+ });
56
+ if (!response.ok)
57
+ throw new Error(`GitHub request failed (${response.status}): ${url}${response.status === 403 || response.status === 429 ? '. The public API may be rate-limited; try again later.' : ''}`);
58
+ if (!response.body)
59
+ throw new Error(`Empty response: ${url}`);
60
+ const reader = response.body.getReader();
61
+ const chunks = [];
62
+ let size = 0;
63
+ try {
64
+ while (true) {
65
+ const { done, value } = await reader.read();
66
+ if (done)
67
+ break;
68
+ size += value.length;
69
+ if (size > limit)
70
+ throw new Error(`Download exceeds ${limit} bytes: ${url}`);
71
+ chunks.push(value);
72
+ }
73
+ }
74
+ finally {
75
+ await reader.cancel();
76
+ }
77
+ return Buffer.concat(chunks);
78
+ };
79
+ const treeSchema = z.object({
80
+ truncated: z.boolean(),
81
+ tree: z.array(z.object({
82
+ path: z.string(),
83
+ type: z.string(),
84
+ mode: z.string(),
85
+ sha: z.string().regex(/^[a-f0-9]{40}$/),
86
+ size: z.number().optional(),
87
+ })),
88
+ });
89
+ function skillNames(source) {
90
+ const names = source.skills.map((p) => path.posix.basename(p));
91
+ if (new Set(names).size !== names.length)
92
+ throw new Error(`External kit has duplicate skill names: ${names.join(', ')}`);
93
+ return names;
94
+ }
95
+ function snapshotIntegrity(snapshot) {
96
+ const source = snapshot.source;
97
+ const files = Object.entries(snapshot.files)
98
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
99
+ .map(([name, file]) => [name, file.sha, file.mode]);
100
+ return createHash('sha256')
101
+ .update(JSON.stringify([
102
+ source.repo,
103
+ source.ref,
104
+ source.skills,
105
+ source.license,
106
+ files,
107
+ ]))
108
+ .digest('hex');
109
+ }
110
+ function validateSnapshot(snapshot, label) {
111
+ if (snapshot.integrity !== snapshotIntegrity(snapshot))
112
+ throw new Error(`${label}: external snapshot manifest checksum mismatch`);
113
+ const names = skillNames(snapshot.source);
114
+ let size = 0;
115
+ if (Object.keys(snapshot.files).length > MAX_FILES)
116
+ throw new Error(`${label}: too many external files`);
117
+ for (const [file, stored] of Object.entries(snapshot.files)) {
118
+ if (!names.some((name) => file.startsWith(`${name}/`)))
119
+ throw new Error(`${label}: invalid external output ${file}`);
120
+ const data = Buffer.from(stored.data, 'base64');
121
+ size += data.length;
122
+ if (data.toString('base64') !== stored.data ||
123
+ blobHash(data) !== stored.sha)
124
+ throw new Error(`${label}: external snapshot checksum mismatch: ${file}`);
125
+ if (data.length > MAX_FILE || size > MAX_KIT)
126
+ throw new Error(`${label}: external snapshot exceeds size limit`);
127
+ }
128
+ for (const name of names) {
129
+ const file = snapshot.files[`${name}/SKILL.md`];
130
+ if (!file)
131
+ throw new Error(`${label}: missing ${name}/SKILL.md`);
132
+ const text = Buffer.from(file.data, 'base64').toString('utf8');
133
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
134
+ const meta = frontmatter ? yaml(frontmatter[1]) : undefined;
135
+ if (!meta ||
136
+ meta.name !== name ||
137
+ typeof meta.description !== 'string' ||
138
+ !meta.description.trim())
139
+ throw new Error(`${label}: ${name}/SKILL.md needs matching name and description`);
140
+ if (!snapshot.files[`${name}/LICENSE.upstream`])
141
+ throw new Error(`${label}: missing upstream license for ${name}`);
142
+ }
143
+ }
144
+ async function download(source, get) {
145
+ skillNames(source);
146
+ const tree = parse(treeSchema, JSON.parse((await get(`https://api.github.com/repos/${source.repo}/git/trees/${source.ref}?recursive=1`, MAX_KIT)).toString()), 'GitHub tree');
147
+ if (tree.truncated)
148
+ throw new Error('GitHub returned a truncated tree; this repository is too large for the external kit loader.');
149
+ const chosen = [];
150
+ const license = tree.tree.find((f) => f.path === source.license);
151
+ if (!license ||
152
+ license.type !== 'blob' ||
153
+ !['100644', '100755'].includes(license.mode))
154
+ throw new Error(`Missing regular license file: ${source.license}`);
155
+ for (const skill of source.skills) {
156
+ const name = path.posix.basename(skill);
157
+ for (const entry of tree.tree.filter((f) => f.path.startsWith(`${skill}/`))) {
158
+ if (entry.type === 'tree')
159
+ continue;
160
+ if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode))
161
+ throw new Error(`Unsupported external symlink or submodule: ${entry.path}`);
162
+ const relative = entry.path.slice(skill.length + 1);
163
+ parse(relativePath, relative, `External path ${entry.path}`);
164
+ chosen.push({
165
+ remote: entry.path,
166
+ target: `${name}/${relative}`,
167
+ sha: entry.sha,
168
+ mode: entry.mode === '100755' ? 0o755 : 0o644,
169
+ size: entry.size ?? MAX_FILE + 1,
170
+ });
171
+ }
172
+ chosen.push({
173
+ remote: source.license,
174
+ target: `${name}/LICENSE.upstream`,
175
+ sha: license.sha,
176
+ mode: 0o644,
177
+ size: license.size ?? MAX_FILE + 1,
178
+ });
179
+ }
180
+ if (chosen.length > MAX_FILES ||
181
+ chosen.some((f) => f.size > MAX_FILE) ||
182
+ chosen.reduce((sum, f) => sum + f.size, 0) > MAX_KIT)
183
+ throw new Error('External kit exceeds file count or size limits.');
184
+ const files = {};
185
+ for (let start = 0; start < chosen.length; start += 6) {
186
+ const entries = await Promise.all(chosen.slice(start, start + 6).map(async (file) => {
187
+ const encoded = file.remote
188
+ .split('/')
189
+ .map(encodeURIComponent)
190
+ .join('/');
191
+ const content = await get(`https://raw.githubusercontent.com/${source.repo}/${source.ref}/${encoded}`, MAX_FILE);
192
+ if (content.length !== file.size || blobHash(content) !== file.sha)
193
+ throw new Error(`GitHub content checksum mismatch: ${file.remote}`);
194
+ return {
195
+ file,
196
+ stored: {
197
+ data: content.toString('base64'),
198
+ sha: file.sha,
199
+ mode: file.mode,
200
+ },
201
+ };
202
+ }));
203
+ for (const { file, stored } of entries) {
204
+ if (Object.hasOwn(files, file.target))
205
+ throw new Error(`External output collision: ${file.target}`);
206
+ files[file.target] = stored;
207
+ }
208
+ }
209
+ const snapshot = {
210
+ source,
211
+ files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b))),
212
+ };
213
+ const complete = { ...snapshot, integrity: snapshotIntegrity(snapshot) };
214
+ validateSnapshot(complete, source.repo);
215
+ return complete;
216
+ }
217
+ export async function renderWithExternal(catalog, state, options = {}) {
218
+ const result = render(catalog, state);
219
+ const enabled = resolveKits(catalog, state.selected);
220
+ const { raw, store } = readExternal(catalog.root);
221
+ for (const id of options.update ?? [])
222
+ if (!enabled.includes(id) || !catalog.kits.get(id)?.external)
223
+ throw new Error(`Cannot update ${id}: choose an enabled external kit.`);
224
+ for (const id of enabled) {
225
+ const kit = catalog.kits.get(id);
226
+ const source = kit.external;
227
+ if (!source)
228
+ continue;
229
+ let snapshot = Object.hasOwn(store.kits, id) ? store.kits[id] : undefined;
230
+ const unchanged = snapshot && sameSource(snapshot.source, source);
231
+ if (!snapshot ||
232
+ (options.update?.includes(id) && !(options.offline && unchanged))) {
233
+ if (options.offline)
234
+ throw new Error(`${id} is not available at the requested revision offline. Run without --offline once to fetch it.`);
235
+ options.onFetch?.(id, source);
236
+ snapshot = await retryDownload(async () => {
237
+ const controller = new AbortController();
238
+ const requests = new Map();
239
+ const get = (url, limit) => {
240
+ if (!requests.has(url))
241
+ requests.set(url, (options.fetch ?? fetchBytes)(url, limit, controller.signal));
242
+ return requests.get(url);
243
+ };
244
+ try {
245
+ return await download(source, get);
246
+ }
247
+ finally {
248
+ controller.abort();
249
+ await Promise.allSettled(requests.values());
250
+ requests.clear();
251
+ }
252
+ }, options.retry ? (error) => options.retry(id, error) : undefined, () => options.onRetry?.(id));
253
+ store.kits[id] = snapshot;
254
+ }
255
+ validateSnapshot(snapshot, id);
256
+ for (const agent of agents) {
257
+ const prefix = `${agent === 'codex' ? '.agents' : '.claude'}/skills`;
258
+ for (const name of skillNames(snapshot.source)) {
259
+ const destination = `${prefix}/${name}`;
260
+ if (result.skillRoots.has(destination))
261
+ throw new Error(`Output collision: multiple kits target ${destination}`);
262
+ result.skillRoots.add(destination);
263
+ }
264
+ for (const [relative, file] of Object.entries(snapshot.files)) {
265
+ const destination = `${prefix}/${relative}`;
266
+ if (result.files.has(destination))
267
+ throw new Error(`Output collision: ${destination}`);
268
+ result.files.set(destination, {
269
+ content: Buffer.from(file.data, 'base64'),
270
+ mode: portableMode(file.mode),
271
+ });
272
+ }
273
+ }
274
+ }
275
+ if (raw || Object.keys(store.kits).length)
276
+ result.external = {
277
+ before: raw,
278
+ content: json(parse(storeSchema, {
279
+ ...store,
280
+ kits: Object.fromEntries(Object.entries(store.kits).sort(([a], [b]) => a.localeCompare(b))),
281
+ }, 'External snapshots')),
282
+ };
283
+ result.files = new Map([...result.files].sort(([a], [b]) => a.localeCompare(b)));
284
+ return result;
285
+ }
package/dist/fs.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare function exists(file: string): boolean;
2
+ export declare function safePath(root: string, relative: string): string;
3
+ export declare function readOptional(root: string, relative: string): Buffer | undefined;
4
+ export declare function walk(root: string): string[];
5
+ export declare function json(value: unknown): Buffer;
6
+ export declare function portableMode(mode: number): number;
package/dist/fs.js ADDED
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export function exists(file) {
4
+ try {
5
+ fs.lstatSync(file);
6
+ return true;
7
+ }
8
+ catch (error) {
9
+ if (error.code === 'ENOENT')
10
+ return false;
11
+ throw error;
12
+ }
13
+ }
14
+ // Reject symlinks on every existing component, including dangling links.
15
+ export function safePath(root, relative) {
16
+ const absolute = path.resolve(root, relative);
17
+ const local = path.relative(root, absolute);
18
+ if (!local ||
19
+ local === '..' ||
20
+ local.startsWith(`..${path.sep}`) ||
21
+ path.isAbsolute(local))
22
+ throw new Error(`Path escapes its permitted root: ${relative}`);
23
+ let current = root;
24
+ for (const component of local.split(path.sep)) {
25
+ current = path.join(current, component);
26
+ if (exists(current) && fs.lstatSync(current).isSymbolicLink())
27
+ throw new Error(`Symlinks are not supported: ${current}`);
28
+ }
29
+ return absolute;
30
+ }
31
+ export function readOptional(root, relative) {
32
+ const file = safePath(root, relative);
33
+ if (!exists(file))
34
+ return undefined;
35
+ if (!fs.statSync(file).isFile())
36
+ throw new Error(`Expected a regular file: ${relative}`);
37
+ return fs.readFileSync(file);
38
+ }
39
+ export function walk(root) {
40
+ const files = [];
41
+ for (const entry of fs.readdirSync(root).sort()) {
42
+ const file = safePath(root, entry);
43
+ const stat = fs.statSync(file);
44
+ if (stat.isDirectory())
45
+ files.push(...walk(file).map((p) => `${entry}/${p}`));
46
+ else if (stat.isFile())
47
+ files.push(entry);
48
+ else
49
+ throw new Error(`Unsupported file type: ${file}`);
50
+ }
51
+ return files;
52
+ }
53
+ export function json(value) {
54
+ return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
55
+ }
56
+ // Windows does not preserve POSIX executable/permission bits. Compare its
57
+ // generated files by content and use one stable mode in ownership metadata.
58
+ export function portableMode(mode) {
59
+ return process.platform === 'win32' ? 0o644 : mode & 0o777;
60
+ }
package/dist/init.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function initialize(cwd: string, global?: boolean): void;
package/dist/init.js ADDED
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { exists, safePath } from './fs.js';
5
+ import { ignoredText } from './storage.js';
6
+ export function initialize(cwd, global = fs.realpathSync(cwd) === fs.realpathSync(os.homedir())) {
7
+ const root = fs.realpathSync(cwd);
8
+ const target = safePath(root, '.loadout');
9
+ if (exists(target))
10
+ throw new Error('.loadout already exists. Refusing to replace an existing catalog.');
11
+ const ignore = safePath(root, '.gitignore');
12
+ const original = exists(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
13
+ const updated = ignoredText(original, []); // Validate before creating anything.
14
+ try {
15
+ const starterFiles = {
16
+ 'config.yaml': 'schemaVersion: 1\n',
17
+ };
18
+ if (!global) {
19
+ starterFiles['kits/starter/kit.yaml'] = `schemaVersion: 1
20
+ id: starter
21
+ description: Add your repository guidance
22
+ ready: false
23
+ outputs:
24
+ - type: skill
25
+ source: skills/starter
26
+ `;
27
+ starterFiles['kits/starter/skills/starter/SKILL.md'] = `---
28
+ name: starter
29
+ description: Replace this with when to use your repository skill.
30
+ ---
31
+
32
+ # Starter
33
+
34
+ This is an unfinished template. It supplies no agent instructions.
35
+
36
+ Replace this text with your repository workflow, update the description,
37
+ and set ready: true in the kit's kit.yaml when it is ready to use.
38
+ `;
39
+ }
40
+ for (const [relative, content] of Object.entries(starterFiles)) {
41
+ const file = path.join(target, relative);
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ fs.writeFileSync(file, content, { flag: 'wx' });
44
+ }
45
+ fs.writeFileSync(ignore, updated);
46
+ }
47
+ catch (error) {
48
+ fs.rmSync(target, { recursive: true, force: true });
49
+ throw error;
50
+ }
51
+ }
@@ -0,0 +1,10 @@
1
+ import { checkbox, confirm, select } from '@inquirer/prompts';
2
+ import { type Catalog, type State } from './schema.js';
3
+ import { type TargetSelection } from './picker.js';
4
+ import { type Target } from './targets.js';
5
+ export declare function interactive(targets: Target[], initial?: number): Promise<TargetSelection[]>;
6
+ export declare function configureSelection(target: Target, state: State, context?: Parameters<typeof confirm>[1]): Promise<State>;
7
+ export declare function confirmApply(): Promise<boolean>;
8
+ export declare function selectUpdates(catalog: Catalog, state: State, offline?: boolean, context?: Parameters<typeof checkbox>[1]): Promise<string[]>;
9
+ export declare function confirmRetry(id: string, error: Error, context?: Parameters<typeof select>[1]): Promise<boolean>;
10
+ export declare function confirmAdoption(paths: string[], context?: Parameters<typeof confirm>[1]): Promise<boolean>;
@@ -0,0 +1,82 @@
1
+ import { checkbox, confirm, select } from '@inquirer/prompts';
2
+ import { configure } from './resolve.js';
3
+ import { validAnswer } from './schema.js';
4
+ import { targetPicker } from './picker.js';
5
+ import { initializeTarget } from './targets.js';
6
+ import { availableUpdates, updateDescription } from './updates.js';
7
+ export async function interactive(targets, initial = 0) {
8
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
9
+ throw new Error('Interactive setup needs a terminal. Use loadout enable <kit>, then loadout apply.');
10
+ const chosen = await targetPicker({
11
+ targets,
12
+ initial,
13
+ initialize: initializeTarget,
14
+ });
15
+ const configured = [];
16
+ for (const { target, state } of chosen) {
17
+ const value = await configureSelection(target, state);
18
+ configured.push({ target, state: value });
19
+ }
20
+ return configured;
21
+ }
22
+ export async function configureSelection(target, state, context) {
23
+ const change = new Map();
24
+ return configure(target.catalog, state, async (kit, key, question, value) => {
25
+ if (!change.has(kit)) {
26
+ const hasSaved = Object.entries(target.catalog.kits.get(kit).questions).some(([name, q]) => validAnswer(q, state.answers[kit]?.[name]));
27
+ change.set(kit, hasSaved
28
+ ? await confirm({
29
+ message: `${target.label} · ${kit} · Change selection?`,
30
+ default: false,
31
+ }, context)
32
+ : true);
33
+ }
34
+ const saved = state.answers[kit]?.[key];
35
+ if (!change.get(kit) && validAnswer(question, saved))
36
+ return saved;
37
+ const message = `${target.label} · ${kit} · ${question.message}`;
38
+ if (question.type === 'boolean')
39
+ return confirm({ message, default: typeof value === 'boolean' ? value : false }, context);
40
+ return select({
41
+ message,
42
+ choices: question.choices.map((v) => ({ name: v, value: v })),
43
+ default: typeof value === 'string' ? value : undefined,
44
+ }, context);
45
+ });
46
+ }
47
+ export async function confirmApply() {
48
+ return confirm({ message: 'Apply changes?', default: true });
49
+ }
50
+ export async function selectUpdates(catalog, state, offline = false, context) {
51
+ const updates = availableUpdates(catalog, state.selected);
52
+ if (!updates.length)
53
+ return [];
54
+ if (offline) {
55
+ console.log(`${updates.length} catalog update(s) available. Run loadout online to review them; keeping saved versions.`);
56
+ return [];
57
+ }
58
+ return checkbox({
59
+ message: 'Catalog updates available · choose kits to update (Enter skips)',
60
+ choices: updates.map((kit) => ({
61
+ name: kit.id,
62
+ value: kit.id,
63
+ description: updateDescription(kit),
64
+ })),
65
+ required: false,
66
+ }, context);
67
+ }
68
+ export async function confirmRetry(id, error, context) {
69
+ return select({
70
+ message: `${id}: ${error.message}`,
71
+ choices: [
72
+ { name: 'Retry download', value: true },
73
+ { name: 'Cancel', value: false },
74
+ ],
75
+ }, context);
76
+ }
77
+ export async function confirmAdoption(paths, context) {
78
+ return confirm({
79
+ message: `Keep existing content and let Loadout manage ${paths.join(', ')}? Originals will be restored when disabled.`,
80
+ default: true,
81
+ }, context);
82
+ }
@@ -0,0 +1 @@
1
+ export declare function isOutput(relative: string, global?: boolean): boolean;
@@ -0,0 +1,15 @@
1
+ import path from 'node:path';
2
+ import { scopeSchema } from './schema.js';
3
+ export function isOutput(relative, global = false) {
4
+ if (global && ['.codex/AGENTS.md', '.claude/CLAUDE.md'].includes(relative))
5
+ return true;
6
+ if (relative.includes('\\') ||
7
+ relative.split('/').some((p) => !p || p === '.' || p === '..') ||
8
+ /[\x00-\x1f]/.test(relative))
9
+ return false;
10
+ if (/^\.(agents|claude)\/skills\/[a-z0-9]+(?:-[a-z0-9]+)*\/.+/.test(relative))
11
+ return true;
12
+ const directory = path.posix.dirname(relative);
13
+ return (['AGENTS.md', 'CLAUDE.md'].includes(path.posix.basename(relative)) &&
14
+ scopeSchema.safeParse(directory).success);
15
+ }
@@ -0,0 +1,29 @@
1
+ import { type Catalog, type State } from './schema.js';
2
+ import { type Target } from './targets.js';
3
+ export type PickerConfig = {
4
+ catalog: Catalog;
5
+ selected: string[];
6
+ columns?: number;
7
+ rows?: number;
8
+ };
9
+ export type TargetPickerConfig = {
10
+ targets: Target[];
11
+ initial?: number;
12
+ columns?: number;
13
+ rows?: number;
14
+ initialize?: (target: Target) => Target;
15
+ };
16
+ export type TargetSelection = {
17
+ target: Target;
18
+ state: State;
19
+ };
20
+ declare const renderPicker: import("@inquirer/type").Prompt<TargetSelection[], {
21
+ targets: Target[];
22
+ initial?: number;
23
+ columns?: number;
24
+ rows?: number;
25
+ initialize?: (target: Target) => Target;
26
+ } & TargetPickerConfig>;
27
+ export declare function targetPicker(config: TargetPickerConfig, context?: Parameters<typeof renderPicker>[1]): Promise<TargetSelection[]>;
28
+ export declare function kitPicker(config: PickerConfig, context?: Parameters<typeof targetPicker>[1]): Promise<string[]>;
29
+ export {};