@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.
package/dist/cli.js ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createTwoFilesPatch } from 'diff';
4
+ import { readFileSync, realpathSync } from 'node:fs';
5
+ import os from 'node:os';
6
+ import { discover } from './catalog.js';
7
+ import { initialize } from './init.js';
8
+ import { interactive, confirmApply, selectUpdates, confirmRetry, confirmAdoption, } from './interactive.js';
9
+ import { apply, applyAll, plan } from './storage.js';
10
+ import { renderWithExternal } from './external.js';
11
+ import { DownloadCancelledError } from './retry.js';
12
+ import { loadTarget } from './targets.js';
13
+ import { configure, disableKits, reasons, resolveKits, setAnswers, } from './resolve.js';
14
+ import { kitSource } from './schema.js';
15
+ import { availableUpdates, hasUpdate, updateDescription } from './updates.js';
16
+ const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
17
+ const program = new Command()
18
+ .name('loadout')
19
+ .description('Configure your repository’s agent tools.')
20
+ .version(version)
21
+ .option('-g, --global', 'use the Loadout catalog in your home directory')
22
+ .option('--offline', 'use saved external kits without network requests')
23
+ .option('-C, --cwd <directory>', 'run in a different directory', process.cwd())
24
+ .showHelpAfterError();
25
+ function targetRoot() {
26
+ const opts = program.opts();
27
+ const home = realpathSync(os.homedir());
28
+ const root = opts.global ? home : discover(opts.cwd);
29
+ return { root, global: root === home };
30
+ }
31
+ function context() {
32
+ const { root, global } = targetRoot();
33
+ const target = loadTarget(root, global);
34
+ if (!target.catalog || !target.state)
35
+ throw new Error(target.error ??
36
+ `Loadout is not initialized here. Run loadout ${global ? '--global ' : ''}init.`);
37
+ return { catalog: target.catalog, state: target.state };
38
+ }
39
+ function interactiveTargets() {
40
+ const { root, global } = targetRoot();
41
+ const home = realpathSync(os.homedir());
42
+ let local = root;
43
+ if (global) {
44
+ try {
45
+ local = discover(program.opts().cwd);
46
+ }
47
+ catch {
48
+ local = realpathSync(program.opts().cwd);
49
+ }
50
+ }
51
+ const targets = local === home
52
+ ? [loadTarget(home, true)]
53
+ : [loadTarget(local, false), loadTarget(home, true)];
54
+ const initial = global ? targets.length - 1 : 0;
55
+ const current = targets[initial];
56
+ if (!current.catalog)
57
+ throw new Error(current.error ??
58
+ `Loadout is not initialized here. Run loadout ${global ? '--global ' : ''}init.`);
59
+ return { targets, initial };
60
+ }
61
+ function preview(result, diff = false) {
62
+ const changed = result.changes.filter((c) => c.kind !== 'unchanged' && c.path !== '.gitignore');
63
+ if (!changed.length) {
64
+ console.log(result.changes.some((c) => c.kind !== 'unchanged')
65
+ ? 'Ignore rules will be refreshed.'
66
+ : 'Generated files are unchanged.');
67
+ return;
68
+ }
69
+ console.log('\nPlanned changes:');
70
+ for (const change of changed)
71
+ console.log(` ${change.kind === 'create' ? '+' : change.kind === 'delete' ? '-' : '~'} ${change.path}`);
72
+ if (diff) {
73
+ for (const change of changed) {
74
+ if (change.path.startsWith('.loadout/') || change.path === '.gitignore')
75
+ continue;
76
+ const before = change.before?.content ?? Buffer.alloc(0), after = change.after?.content ?? Buffer.alloc(0);
77
+ if (before.equals(after))
78
+ continue;
79
+ if (before.length + after.length > 200_000 ||
80
+ before.includes(0) ||
81
+ after.includes(0)) {
82
+ console.log(` ${change.path}: binary or large content; inspect the source for details.`);
83
+ }
84
+ else {
85
+ console.log(createTwoFilesPatch(`a/${change.path}`, `b/${change.path}`, before.toString('utf8'), after.toString('utf8'), undefined, undefined, { context: 3 }));
86
+ }
87
+ }
88
+ }
89
+ }
90
+ function showDependencies(catalog, state) {
91
+ for (const id of resolveKits(catalog, state.selected))
92
+ if (!state.selected.includes(id))
93
+ console.log(`${id} required by ${reasons(catalog, state.selected, id).join(', ')}`);
94
+ }
95
+ function options(command) {
96
+ return command
97
+ .option('--answer <kit.question=value>', 'set an answer (repeatable)', (value, previous) => [...previous, value], [])
98
+ .option('--adopt', 'preserve existing instructions and adopt identical skills')
99
+ .option('--diff', 'show text changes in generated files')
100
+ .option('--dry-run', 'preview without writing files or saving selections');
101
+ }
102
+ async function generate(catalog, state, opts) {
103
+ setAnswers(catalog, state, opts.answer ?? []);
104
+ const configured = await configure(catalog, state);
105
+ showDependencies(catalog, configured);
106
+ const rendered = await renderWithExternal(catalog, configured, {
107
+ update: opts.update,
108
+ offline: program.opts().offline,
109
+ onRetry: (id) => console.log(`Retrying ${id}…`),
110
+ onFetch: (id, source) => console.log(`Fetching ${id} from ${source.repo}…`),
111
+ });
112
+ for (const id of resolveKits(catalog, configured.selected)) {
113
+ const kit = catalog.kits.get(id);
114
+ if (kit.external) {
115
+ const source = opts.update?.includes(id)
116
+ ? kit.external
117
+ : (kit.pinned ?? kit.external);
118
+ console.log(`${id}: ${source.repo}@${source.ref.slice(0, 12)} · license: ${source.license}`);
119
+ }
120
+ }
121
+ const result = plan(catalog, configured, rendered, { adopt: opts.adopt });
122
+ preview(result, opts.diff || !!opts.update?.length || !!result.adopted?.length);
123
+ if (opts.dryRun)
124
+ console.log('Dry run: no files or selections saved.');
125
+ else {
126
+ const count = apply(result);
127
+ if (count)
128
+ console.log(`\nLoadout applied (${count} files changed).`);
129
+ }
130
+ }
131
+ async function setup() {
132
+ const { targets, initial } = interactiveTargets();
133
+ const configured = await interactive(targets, initial);
134
+ const offline = program.opts().offline;
135
+ const plans = [];
136
+ for (const { target, state } of configured) {
137
+ const catalog = target.catalog;
138
+ console.log(`\n${target.label} · ${target.root}`);
139
+ const update = await selectUpdates(catalog, state, offline);
140
+ const rendered = await renderWithExternal(catalog, state, {
141
+ offline,
142
+ update,
143
+ retry: confirmRetry,
144
+ onRetry: (id) => console.log(`Retrying ${id}…`),
145
+ onFetch: (id, source) => console.log(`Fetching ${id} from ${source.repo}…`),
146
+ });
147
+ for (const id of resolveKits(catalog, state.selected)) {
148
+ const kit = catalog.kits.get(id);
149
+ if (kit.external) {
150
+ const source = update.includes(id)
151
+ ? kit.external
152
+ : (kit.pinned ?? kit.external);
153
+ console.log(`${id}: ${source.repo}@${source.ref.slice(0, 12)} · license: ${source.license}`);
154
+ }
155
+ }
156
+ const result = plan(catalog, state, rendered, { adopt: true });
157
+ preview(result, update.length > 0 || !!result.adopted?.length);
158
+ if (result.adopted?.length && !(await confirmAdoption(result.adopted)))
159
+ throw new DownloadCancelledError();
160
+ plans.push(result);
161
+ }
162
+ if (!plans.some((result) => result.changes.some((c) => c.kind !== 'unchanged')))
163
+ return;
164
+ if (await confirmApply()) {
165
+ applyAll(plans);
166
+ console.log('\nYour loadout is ready.');
167
+ }
168
+ else
169
+ console.log('Cancelled. No kit selections or agent outputs saved.');
170
+ }
171
+ program.action(setup);
172
+ program
173
+ .command('init')
174
+ .description('create a starter catalog and open setup in a terminal')
175
+ .action(async () => {
176
+ const opts = program.opts();
177
+ const root = opts.global ? os.homedir() : opts.cwd;
178
+ const global = !!opts.global || realpathSync(root) === realpathSync(os.homedir());
179
+ initialize(root, global);
180
+ console.log(`Initialized ${realpathSync(root)}/.loadout${global ? '. Choose kits from Browse.' : ' with an editable starter kit.'}`);
181
+ if (process.stdin.isTTY && process.stdout.isTTY)
182
+ await setup();
183
+ else
184
+ console.log(`Run loadout${global ? ' --global' : ''} in a terminal to choose your kits.`);
185
+ });
186
+ program
187
+ .command('list')
188
+ .description('show available kits and why they are enabled')
189
+ .action(() => {
190
+ const { catalog, state } = context();
191
+ const enabled = resolveKits(catalog, state.selected);
192
+ for (const kit of catalog.kits.values()) {
193
+ const status = kit.ready === false
194
+ ? 'Needs setup'
195
+ : state.selected.includes(kit.id)
196
+ ? 'selected'
197
+ : enabled.includes(kit.id)
198
+ ? `required by ${reasons(catalog, state.selected, kit.id).join(', ')}`
199
+ : 'disabled';
200
+ console.log(`${kit.id} [${status}]\n ${kitSource(kit)} · ${kit.description}`);
201
+ }
202
+ if (!catalog.kits.size)
203
+ console.log('No kits found. Add .loadout/kits/<name>/kit.yaml.');
204
+ });
205
+ program
206
+ .command('explain <kit>')
207
+ .description('explain why a kit is enabled')
208
+ .action((id) => {
209
+ const { catalog, state } = context();
210
+ if (!catalog.kits.has(id))
211
+ throw new Error(`Unknown kit: ${id}`);
212
+ const kit = catalog.kits.get(id);
213
+ if (kit.external) {
214
+ const source = kit.pinned ?? kit.external;
215
+ console.log(`Source: https://github.com/${source.repo}/tree/${source.ref}`);
216
+ console.log(`Skills: ${source.skills.map((p) => p.split('/').at(-1)).join(', ')}`);
217
+ if (hasUpdate(kit))
218
+ console.log(`A different source revision is offered. Run loadout update ${id} --dry-run to preview it.`);
219
+ }
220
+ const enabled = resolveKits(catalog, state.selected);
221
+ if (!enabled.includes(id)) {
222
+ console.log(`${id} is disabled.`);
223
+ return;
224
+ }
225
+ if (state.selected.includes(id))
226
+ console.log(`${id} is explicitly selected.`);
227
+ for (const dependent of reasons(catalog, state.selected, id))
228
+ console.log(`${id} is required by ${dependent} (directly or transitively).`);
229
+ });
230
+ options(program
231
+ .command('enable <kits...>')
232
+ .description('enable kits and their dependencies')).action(async (ids, opts) => {
233
+ const { catalog, state } = context();
234
+ state.selected = [...state.selected, ...ids];
235
+ await generate(catalog, state, opts);
236
+ });
237
+ options(program
238
+ .command('disable <kit>')
239
+ .description('disable a kit; preserve dependencies still in use')
240
+ .option('--cascade', 'also disable explicit selections that require this kit')).action(async (id, opts) => {
241
+ const { catalog, state } = context();
242
+ if (!catalog.kits.has(id) && !state.selected.includes(id))
243
+ throw new Error(`Unknown kit: ${id}`);
244
+ state.selected = disableKits(catalog, state.selected, id, opts.cascade ?? false);
245
+ await generate(catalog, state, opts);
246
+ });
247
+ options(program
248
+ .command('apply')
249
+ .description('regenerate using saved selections and answers')).action(async (opts) => {
250
+ const { catalog, state } = context();
251
+ await generate(catalog, state, opts);
252
+ });
253
+ program
254
+ .command('outdated')
255
+ .description('compare downloaded external kits with the current catalog (no network)')
256
+ .action(() => {
257
+ const { catalog, state } = context();
258
+ const enabled = resolveKits(catalog, state.selected);
259
+ const updates = availableUpdates(catalog);
260
+ for (const kit of updates)
261
+ console.log(`${kit.id}${enabled.includes(kit.id) ? '' : ' [disabled]'}\n ${updateDescription(kit)}`);
262
+ console.log(updates.length
263
+ ? '\nRun loadout to review enabled kit updates, or loadout update --dry-run.'
264
+ : 'No catalog updates for downloaded external kits.');
265
+ console.log('Compared with this catalog; upstream branches were not checked.');
266
+ });
267
+ options(program
268
+ .command('update [kits...]')
269
+ .description('update enabled external kits to their catalog revisions')).action(async (ids, opts) => {
270
+ const { catalog, state } = context();
271
+ const update = ids.length
272
+ ? ids
273
+ : availableUpdates(catalog, state.selected).map((kit) => kit.id);
274
+ if (!update.length) {
275
+ console.log('No catalog updates for enabled external kits.');
276
+ return;
277
+ }
278
+ await generate(catalog, state, { ...opts, update });
279
+ });
280
+ try {
281
+ await program.parseAsync();
282
+ }
283
+ catch (error) {
284
+ if (error instanceof Error &&
285
+ ['ExitPromptError', 'AbortPromptError', 'DownloadCancelledError'].includes(error.name)) {
286
+ console.log('\nCancelled. No kit selections or agent outputs saved.');
287
+ process.exitCode = 130;
288
+ }
289
+ else {
290
+ console.error(`loadout: ${error instanceof Error ? error.message : String(error)}`);
291
+ process.exitCode = 1;
292
+ }
293
+ }
@@ -0,0 +1,3 @@
1
+ import { type ExternalKit } from './schema.js';
2
+ export declare const providerDescriptions: Readonly<Record<string, string>>;
3
+ export declare const curatedKits: ExternalKit[];
@@ -0,0 +1,98 @@
1
+ import { externalKitSchema, parse } from './schema.js';
2
+ export const providerDescriptions = {
3
+ 'mattpocock/skills': 'Test-driven development, debugging, planning, and agent workflows',
4
+ 'anthropics/skills': 'Interface design, browser testing, MCP servers, and generative art',
5
+ };
6
+ // Definitions ship with Loadout; upstream content is fetched only when selected.
7
+ const matt = {
8
+ repo: 'mattpocock/skills',
9
+ ref: '3cca18b368ae95cdbdebbff572ccafa662551015',
10
+ license: 'LICENSE',
11
+ };
12
+ const engineering = [
13
+ [
14
+ 'tdd',
15
+ 'Build features test-first with behavior checks that survive refactors',
16
+ ['codebase-design'],
17
+ ],
18
+ [
19
+ 'diagnosing-bugs',
20
+ 'Reproduce a failure, isolate its cause, and verify the fix',
21
+ ],
22
+ [
23
+ 'domain-modeling',
24
+ 'Define shared project terms and capture architecture decisions',
25
+ ],
26
+ [
27
+ 'prototype',
28
+ 'Try an interface or state model before committing to a design',
29
+ ],
30
+ [
31
+ 'research',
32
+ 'Investigate a question and save findings with source citations',
33
+ ],
34
+ [
35
+ 'resolving-merge-conflicts',
36
+ 'Resolve conflicts, verify the result, and commit the merge',
37
+ ],
38
+ ['wizard', 'Create a step-by-step Bash wizard for setup only a human can do'],
39
+ ];
40
+ const productivity = [
41
+ [
42
+ 'grill-me',
43
+ 'Challenge a plan until its assumptions and trade-offs are clear',
44
+ ['grilling'],
45
+ ],
46
+ [
47
+ 'writing-for-agents',
48
+ 'Write concise agent instructions with clear triggers and steps',
49
+ ],
50
+ [
51
+ 'handoff',
52
+ 'Save decisions, progress, and next steps for a fresh agent session',
53
+ ],
54
+ ['teach', 'Learn a topic through guided lessons, practice, and saved notes'],
55
+ [
56
+ 'to-questionnaire',
57
+ 'Draft a questionnaire to get missing answers from someone else',
58
+ ],
59
+ ['wait-what', 'Re-explain the last answer with context and simpler language'],
60
+ ];
61
+ const anthropic = [
62
+ [
63
+ 'frontend-design',
64
+ 'Build interfaces with a clear visual direction, layout, and typography',
65
+ ],
66
+ [
67
+ 'webapp-testing',
68
+ 'Check UI behavior and capture screenshots with Python + Playwright',
69
+ ],
70
+ ['mcp-builder', 'Build and evaluate MCP tools that connect agents to APIs'],
71
+ [
72
+ 'algorithmic-art',
73
+ 'Create p5.js art with repeatable seeds and interactive controls',
74
+ ],
75
+ ];
76
+ export const curatedKits = [
77
+ ...[
78
+ ['engineering', engineering],
79
+ ['productivity', productivity],
80
+ ].flatMap(([group, kits]) => kits.map(([name, description, supporting = []]) => ({
81
+ id: `matt-pocock-${name}`,
82
+ description,
83
+ source: {
84
+ ...matt,
85
+ skills: [name, ...supporting].map((skill) => `skills/${group}/${skill}`),
86
+ },
87
+ }))),
88
+ ...anthropic.map(([name, description]) => ({
89
+ id: `anthropic-${name}`,
90
+ description,
91
+ source: {
92
+ repo: 'anthropics/skills',
93
+ ref: '34040c9c568585f6929bedeaad110ad08f079624',
94
+ skills: [`skills/${name}`],
95
+ license: `skills/${name}/LICENSE.txt`,
96
+ },
97
+ })),
98
+ ].map((kit) => parse(externalKitSchema, kit, 'Curated kit'));
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+ import { type Catalog, type State, type ExternalSource } from './schema.js';
3
+ import { type Rendered } from './render.js';
4
+ declare const snapshotSchema: z.ZodObject<{
5
+ integrity: z.ZodString;
6
+ source: z.ZodObject<{
7
+ repo: z.ZodString;
8
+ ref: z.ZodString;
9
+ skills: z.ZodArray<z.ZodString>;
10
+ license: z.ZodDefault<z.ZodString>;
11
+ }, z.core.$strict>;
12
+ files: z.ZodRecord<z.ZodString, z.ZodObject<{
13
+ data: z.ZodString;
14
+ sha: z.ZodString;
15
+ mode: z.ZodUnion<readonly [z.ZodLiteral<420>, z.ZodLiteral<493>]>;
16
+ }, z.core.$strict>>;
17
+ }, z.core.$strict>;
18
+ declare const storeSchema: z.ZodObject<{
19
+ schemaVersion: z.ZodLiteral<1>;
20
+ kits: z.ZodRecord<z.ZodString, z.ZodObject<{
21
+ integrity: z.ZodString;
22
+ source: z.ZodObject<{
23
+ repo: z.ZodString;
24
+ ref: z.ZodString;
25
+ skills: z.ZodArray<z.ZodString>;
26
+ license: z.ZodDefault<z.ZodString>;
27
+ }, z.core.$strict>;
28
+ files: z.ZodRecord<z.ZodString, z.ZodObject<{
29
+ data: z.ZodString;
30
+ sha: z.ZodString;
31
+ mode: z.ZodUnion<readonly [z.ZodLiteral<420>, z.ZodLiteral<493>]>;
32
+ }, z.core.$strict>>;
33
+ }, z.core.$strict>>;
34
+ }, z.core.$strict>;
35
+ export type Snapshot = z.infer<typeof snapshotSchema>;
36
+ export type ExternalStore = z.infer<typeof storeSchema>;
37
+ export type FetchBytes = (url: string, limit: number, signal?: AbortSignal) => Promise<Buffer>;
38
+ export declare function readExternal(root: string): {
39
+ raw?: Buffer;
40
+ store: ExternalStore;
41
+ };
42
+ export declare const fetchBytes: FetchBytes;
43
+ export declare function renderWithExternal(catalog: Catalog, state: State, options?: {
44
+ fetch?: FetchBytes;
45
+ update?: string[];
46
+ offline?: boolean;
47
+ onFetch?: (id: string, source: ExternalSource) => void;
48
+ onRetry?: (id: string) => void;
49
+ retry?: (id: string, error: Error) => Promise<boolean>;
50
+ }): Promise<Rendered>;
51
+ export {};