@lidtop/loadout 0.1.0 → 0.2.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/README.md +6 -4
- package/dist/adoption.js +4 -4
- package/dist/catalog.js +52 -29
- package/dist/cli.js +24 -14
- package/dist/external.js +2 -2
- package/dist/ignore.d.ts +7 -0
- package/dist/ignore.js +56 -0
- package/dist/init.js +7 -5
- package/dist/interactive.js +0 -2
- package/dist/locations.d.ts +1 -0
- package/dist/locations.js +4 -0
- package/dist/picker.d.ts +0 -2
- package/dist/picker.js +26 -64
- package/dist/render.d.ts +5 -0
- package/dist/render.js +23 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +10 -2
- package/dist/storage.d.ts +16 -1
- package/dist/storage.js +138 -75
- package/dist/targets.d.ts +0 -1
- package/dist/targets.js +1 -9
- package/kits/write-kit/skills/loadout-write-kit/SKILL.md +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Loadout
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Choose agent skills and instructions for yourself or your team. Loadout configures Codex and Claude Code.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Private kits and selections live in `.loadout-personal/`, ignored by Git. Add reusable kits to `~/.loadout/kits/` and enable them per repository.
|
|
6
6
|
|
|
7
7
|
## Use it
|
|
8
8
|
|
|
9
|
-
Requires Node.js **22.13+**.
|
|
9
|
+
Requires Node.js **22.13+**. Run in any repository:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm install -g @lidtop/loadout
|
|
@@ -15,7 +15,9 @@ loadout
|
|
|
15
15
|
|
|
16
16
|
Pick your kits, select **Continue**, and confirm. Run `loadout` again to change your selection.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Conflicting project instructions are skipped with a warning; the kit’s skills still install.
|
|
19
|
+
|
|
20
|
+
## Share kits with your team
|
|
19
21
|
|
|
20
22
|
```sh
|
|
21
23
|
loadout init
|
package/dist/adoption.js
CHANGED
|
@@ -5,7 +5,7 @@ import { z } from 'zod';
|
|
|
5
5
|
import { exists, json, readOptional, safePath, walk, portableMode, } from './fs.js';
|
|
6
6
|
import { isOutput } from './output-path.js';
|
|
7
7
|
import { parse } from './schema.js';
|
|
8
|
-
const manifestPath = '.loadout/adopted.json';
|
|
8
|
+
const manifestPath = '.loadout-personal/adopted.json';
|
|
9
9
|
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
10
10
|
const manifestSchema = z
|
|
11
11
|
.object({
|
|
@@ -61,10 +61,10 @@ export function prepareAdoption(root, rendered, owned, global, allow) {
|
|
|
61
61
|
for (const [file, entry] of Object.entries(manifest.files)) {
|
|
62
62
|
if (!isOutput(file, global) || !Object.hasOwn(owned, file))
|
|
63
63
|
throw new Error(`Unexpected adopted output: ${file}`);
|
|
64
|
-
const blobPath = `.loadout/adopted/${entry.hash}`;
|
|
64
|
+
const blobPath = `.loadout-personal/adopted/${entry.hash}`;
|
|
65
65
|
const blob = read(root, blobPath);
|
|
66
66
|
if (!blob || digest(blob.content) !== entry.hash)
|
|
67
|
-
throw new Error(`Original content is missing or changed for ${file}. Restore its .loadout/adopted backup before applying.`);
|
|
67
|
+
throw new Error(`Original content is missing or changed for ${file}. Restore its .loadout-personal/adopted backup before applying.`);
|
|
68
68
|
blobs.set(blobPath, blob);
|
|
69
69
|
originals.set(file, { content: blob.content, mode: entry.mode });
|
|
70
70
|
}
|
|
@@ -125,7 +125,7 @@ export function prepareAdoption(root, rendered, owned, global, allow) {
|
|
|
125
125
|
});
|
|
126
126
|
const hash = digest(original.content);
|
|
127
127
|
nextManifest.files[file] = { hash, mode: original.mode };
|
|
128
|
-
const blobPath = `.loadout/adopted/${hash}`;
|
|
128
|
+
const blobPath = `.loadout-personal/adopted/${hash}`;
|
|
129
129
|
const existing = blobs.get(blobPath) ?? read(root, blobPath);
|
|
130
130
|
if (existing && !existing.content.equals(original.content))
|
|
131
131
|
throw new Error(`Original backup changed: ${blobPath}`);
|
package/dist/catalog.js
CHANGED
|
@@ -15,13 +15,16 @@ export function discover(cwd) {
|
|
|
15
15
|
// A home catalog is global, never an implicit catalog for child projects.
|
|
16
16
|
if (root === home && root !== initial)
|
|
17
17
|
break;
|
|
18
|
-
if (exists(path.join(root, '.loadout/config.yaml'))
|
|
18
|
+
if (exists(path.join(root, '.loadout/config.yaml')) ||
|
|
19
|
+
exists(path.join(root, '.loadout-personal')))
|
|
19
20
|
return root;
|
|
20
|
-
if (exists(path.join(root, '.git'))
|
|
21
|
+
if (exists(path.join(root, '.git')))
|
|
22
|
+
return root;
|
|
23
|
+
if (path.dirname(root) === root)
|
|
21
24
|
break;
|
|
22
25
|
root = path.dirname(root);
|
|
23
26
|
}
|
|
24
|
-
|
|
27
|
+
return initial;
|
|
25
28
|
}
|
|
26
29
|
function readYaml(root, relative) {
|
|
27
30
|
try {
|
|
@@ -31,7 +34,7 @@ function readYaml(root, relative) {
|
|
|
31
34
|
throw new Error(`${relative}: ${error.message}`);
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
|
-
function validateKit(kit
|
|
37
|
+
function validateKit(kit) {
|
|
35
38
|
if (kit.ready === false)
|
|
36
39
|
return;
|
|
37
40
|
const dir = kit.directory;
|
|
@@ -51,15 +54,8 @@ function validateKit(kit, root, global) {
|
|
|
51
54
|
throw new Error(`${kit.id}: invalid condition on ${output.when.answer}`);
|
|
52
55
|
}
|
|
53
56
|
if (output.type === 'instructions') {
|
|
54
|
-
if (global && output.scope !== '.')
|
|
55
|
-
throw new Error(`${kit.id}: global instructions must use scope: .`);
|
|
56
57
|
if (!fs.statSync(source).isFile())
|
|
57
58
|
throw new Error(`${kit.id}: instructions source must be a file`);
|
|
58
|
-
if (output.scope !== '.') {
|
|
59
|
-
const scope = safePath(root, output.scope);
|
|
60
|
-
if (!exists(scope) || !fs.statSync(scope).isDirectory())
|
|
61
|
-
throw new Error(`${kit.id}: scope directory does not exist: ${output.scope}`);
|
|
62
|
-
}
|
|
63
59
|
}
|
|
64
60
|
else {
|
|
65
61
|
if (!fs.statSync(source).isDirectory())
|
|
@@ -83,24 +79,51 @@ function validateKit(kit, root, global) {
|
|
|
83
79
|
}
|
|
84
80
|
}
|
|
85
81
|
export function loadCatalog(root, global = path.resolve(root) === fs.realpathSync(os.homedir())) {
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
82
|
+
const home = fs.realpathSync(os.homedir());
|
|
83
|
+
const sources = [
|
|
84
|
+
...(!global && root !== home
|
|
85
|
+
? [
|
|
86
|
+
{ root: home, directory: '.loadout', personal: true },
|
|
87
|
+
{ root: home, directory: '.loadout-personal', personal: true },
|
|
88
|
+
]
|
|
89
|
+
: []),
|
|
90
|
+
{ root, directory: '.loadout', personal: global },
|
|
91
|
+
{ root, directory: '.loadout-personal', personal: true },
|
|
92
|
+
];
|
|
93
|
+
const configs = sources.map((source) => {
|
|
94
|
+
const file = `${source.directory}/config.yaml`;
|
|
95
|
+
const present = exists(safePath(source.root, file));
|
|
96
|
+
return {
|
|
97
|
+
...source,
|
|
98
|
+
present,
|
|
99
|
+
config: parse(configSchema, present ? readYaml(source.root, file) : { schemaVersion: 1 }, `${source.root}/${file}`),
|
|
99
100
|
};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
});
|
|
102
|
+
const config = {
|
|
103
|
+
curated: [...configs].reverse().find((source) => source.present)?.config.curated ??
|
|
104
|
+
true,
|
|
105
|
+
externalKits: configs.flatMap(({ config }) => config.externalKits),
|
|
106
|
+
};
|
|
107
|
+
const kits = new Map();
|
|
108
|
+
for (const source of configs) {
|
|
109
|
+
const directory = safePath(source.root, `${source.directory}/kits`);
|
|
110
|
+
for (const folder of exists(directory)
|
|
111
|
+
? fs.readdirSync(directory).sort()
|
|
112
|
+
: []) {
|
|
113
|
+
const dir = safePath(directory, folder);
|
|
114
|
+
if (!fs.statSync(dir).isDirectory())
|
|
115
|
+
continue;
|
|
116
|
+
const manifest = `${source.directory}/kits/${folder}/kit.yaml`;
|
|
117
|
+
const kit = {
|
|
118
|
+
...parse(kitSchema, readYaml(source.root, manifest), manifest),
|
|
119
|
+
directory: dir,
|
|
120
|
+
...(source.personal ? { origin: 'personal' } : {}),
|
|
121
|
+
};
|
|
122
|
+
if (kits.has(kit.id))
|
|
123
|
+
throw new Error(`Duplicate kit ID: ${kit.id}. Personal and repository kits must have distinct IDs.`);
|
|
124
|
+
validateKit(kit);
|
|
125
|
+
kits.set(kit.id, kit);
|
|
126
|
+
}
|
|
104
127
|
}
|
|
105
128
|
for (const [definitions, origin] of [
|
|
106
129
|
[config.curated ? curatedKits : [], 'curated'],
|
|
@@ -137,7 +160,7 @@ export function loadCatalog(root, global = path.resolve(root) === fs.realpathSyn
|
|
|
137
160
|
};
|
|
138
161
|
if (kits.has(kit.id))
|
|
139
162
|
throw new Error(`Duplicate kit ID: ${kit.id}`);
|
|
140
|
-
validateKit(kit
|
|
163
|
+
validateKit(kit);
|
|
141
164
|
kits.set(kit.id, kit);
|
|
142
165
|
}
|
|
143
166
|
}
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import os from 'node:os';
|
|
|
6
6
|
import { discover } from './catalog.js';
|
|
7
7
|
import { initialize } from './init.js';
|
|
8
8
|
import { interactive, confirmApply, selectUpdates, confirmRetry, confirmAdoption, } from './interactive.js';
|
|
9
|
-
import { apply, applyAll, plan } from './storage.js';
|
|
9
|
+
import { apply, applyAll, hasChanges, plan } from './storage.js';
|
|
10
10
|
import { renderWithExternal } from './external.js';
|
|
11
11
|
import { DownloadCancelledError } from './retry.js';
|
|
12
12
|
import { loadTarget } from './targets.js';
|
|
@@ -18,7 +18,7 @@ const program = new Command()
|
|
|
18
18
|
.name('loadout')
|
|
19
19
|
.description('Configure your repository’s agent tools.')
|
|
20
20
|
.version(version)
|
|
21
|
-
.option('-g, --global', '
|
|
21
|
+
.option('-g, --global', 'enable kits for all repositories')
|
|
22
22
|
.option('--offline', 'use saved external kits without network requests')
|
|
23
23
|
.option('-C, --cwd <directory>', 'run in a different directory', process.cwd())
|
|
24
24
|
.showHelpAfterError();
|
|
@@ -32,8 +32,7 @@ function context() {
|
|
|
32
32
|
const { root, global } = targetRoot();
|
|
33
33
|
const target = loadTarget(root, global);
|
|
34
34
|
if (!target.catalog || !target.state)
|
|
35
|
-
throw new Error(target.error ??
|
|
36
|
-
`Loadout is not initialized here. Run loadout ${global ? '--global ' : ''}init.`);
|
|
35
|
+
throw new Error(target.error ?? 'Cannot load kits for this location.');
|
|
37
36
|
return { catalog: target.catalog, state: target.state };
|
|
38
37
|
}
|
|
39
38
|
function interactiveTargets() {
|
|
@@ -54,15 +53,21 @@ function interactiveTargets() {
|
|
|
54
53
|
const initial = global ? targets.length - 1 : 0;
|
|
55
54
|
const current = targets[initial];
|
|
56
55
|
if (!current.catalog)
|
|
57
|
-
throw new Error(current.error ??
|
|
58
|
-
`Loadout is not initialized here. Run loadout ${global ? '--global ' : ''}init.`);
|
|
56
|
+
throw new Error(current.error ?? 'Cannot load kits for this location.');
|
|
59
57
|
return { targets, initial };
|
|
60
58
|
}
|
|
59
|
+
function showSkipped(result, preview = false) {
|
|
60
|
+
for (const skipped of result.skippedInstructions ?? [])
|
|
61
|
+
console.log(`Skipped instructions from ${skipped.kits.join(', ')} (${skipped.paths.join(', ')}): ${skipped.reason}.`);
|
|
62
|
+
for (const id of result.kitsWithoutOutputs ?? [])
|
|
63
|
+
console.log(`${id}: no agent outputs ${preview ? 'will be applied' : 'applied'}; all instructions skipped.`);
|
|
64
|
+
}
|
|
61
65
|
function preview(result, diff = false) {
|
|
62
|
-
|
|
66
|
+
showSkipped(result, true);
|
|
67
|
+
const changed = result.changes.filter((c) => c.kind !== 'unchanged' && !c.path.startsWith('.loadout-personal/'));
|
|
63
68
|
if (!changed.length) {
|
|
64
|
-
console.log(result
|
|
65
|
-
? '
|
|
69
|
+
console.log(hasChanges(result)
|
|
70
|
+
? 'Local settings will be refreshed.'
|
|
66
71
|
: 'Generated files are unchanged.');
|
|
67
72
|
return;
|
|
68
73
|
}
|
|
@@ -71,8 +76,6 @@ function preview(result, diff = false) {
|
|
|
71
76
|
console.log(` ${change.kind === 'create' ? '+' : change.kind === 'delete' ? '-' : '~'} ${change.path}`);
|
|
72
77
|
if (diff) {
|
|
73
78
|
for (const change of changed) {
|
|
74
|
-
if (change.path.startsWith('.loadout/') || change.path === '.gitignore')
|
|
75
|
-
continue;
|
|
76
79
|
const before = change.before?.content ?? Buffer.alloc(0), after = change.after?.content ?? Buffer.alloc(0);
|
|
77
80
|
if (before.equals(after))
|
|
78
81
|
continue;
|
|
@@ -126,6 +129,7 @@ async function generate(catalog, state, opts) {
|
|
|
126
129
|
const count = apply(result);
|
|
127
130
|
if (count)
|
|
128
131
|
console.log(`\nLoadout applied (${count} files changed).`);
|
|
132
|
+
showSkipped(result);
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
async function setup() {
|
|
@@ -159,11 +163,17 @@ async function setup() {
|
|
|
159
163
|
throw new DownloadCancelledError();
|
|
160
164
|
plans.push(result);
|
|
161
165
|
}
|
|
162
|
-
if (!plans.some(
|
|
166
|
+
if (!plans.some(hasChanges))
|
|
163
167
|
return;
|
|
164
168
|
if (await confirmApply()) {
|
|
165
169
|
applyAll(plans);
|
|
166
170
|
console.log('\nYour loadout is ready.');
|
|
171
|
+
for (const result of plans) {
|
|
172
|
+
if (!result.skippedInstructions?.length)
|
|
173
|
+
continue;
|
|
174
|
+
console.log(result.root);
|
|
175
|
+
showSkipped(result);
|
|
176
|
+
}
|
|
167
177
|
}
|
|
168
178
|
else
|
|
169
179
|
console.log('Cancelled. No kit selections or agent outputs saved.');
|
|
@@ -171,7 +181,7 @@ async function setup() {
|
|
|
171
181
|
program.action(setup);
|
|
172
182
|
program
|
|
173
183
|
.command('init')
|
|
174
|
-
.description('create
|
|
184
|
+
.description('create an optional shared catalog and open setup')
|
|
175
185
|
.action(async () => {
|
|
176
186
|
const opts = program.opts();
|
|
177
187
|
const root = opts.global ? os.homedir() : opts.cwd;
|
|
@@ -200,7 +210,7 @@ program
|
|
|
200
210
|
console.log(`${kit.id} [${status}]\n ${kitSource(kit)} · ${kit.description}`);
|
|
201
211
|
}
|
|
202
212
|
if (!catalog.kits.size)
|
|
203
|
-
console.log('No kits found. Add
|
|
213
|
+
console.log('No kits found. Add ~/.loadout/kits/<name>/kit.yaml.');
|
|
204
214
|
});
|
|
205
215
|
program
|
|
206
216
|
.command('explain <kit>')
|
package/dist/external.js
CHANGED
|
@@ -31,11 +31,11 @@ const storeSchema = z
|
|
|
31
31
|
})
|
|
32
32
|
.strict();
|
|
33
33
|
export function readExternal(root) {
|
|
34
|
-
const raw = readOptional(root, '.loadout/external.json');
|
|
34
|
+
const raw = readOptional(root, '.loadout-personal/external.json');
|
|
35
35
|
return {
|
|
36
36
|
raw,
|
|
37
37
|
store: raw
|
|
38
|
-
? parse(storeSchema, JSON.parse(raw.toString()), '.loadout/external.json')
|
|
38
|
+
? parse(storeSchema, JSON.parse(raw.toString()), '.loadout-personal/external.json')
|
|
39
39
|
: { schemaVersion: 1, kits: {} },
|
|
40
40
|
};
|
|
41
41
|
}
|
package/dist/ignore.d.ts
ADDED
package/dist/ignore.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
// Git resolves the common metadata directory for linked worktrees and repos
|
|
6
|
+
// whose .git is a file. Exclude patterns are relative to the working-tree root.
|
|
7
|
+
export function ignoreTarget(root) {
|
|
8
|
+
try {
|
|
9
|
+
const git = (...args) => execFileSync('git', ['-C', root, 'rev-parse', ...args], {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
12
|
+
}).replace(/\r?\n$/, '');
|
|
13
|
+
const topPath = git('--show-toplevel');
|
|
14
|
+
const exclude = git('--path-format=absolute', '--git-path', 'info/exclude');
|
|
15
|
+
if (!path.isAbsolute(topPath) || !path.isAbsolute(exclude))
|
|
16
|
+
throw new Error('Git returned a non-absolute repository path.');
|
|
17
|
+
const top = fs.realpathSync(topPath);
|
|
18
|
+
const prefix = path.relative(top, root).split(path.sep).join('/');
|
|
19
|
+
if (/[\r\n]/.test(prefix))
|
|
20
|
+
throw new Error('Catalog paths containing newlines cannot be ignored.');
|
|
21
|
+
return {
|
|
22
|
+
root: fs.realpathSync(path.resolve(exclude, '../..')),
|
|
23
|
+
key: createHash('sha256').update(root).digest('hex').slice(0, 16),
|
|
24
|
+
prefix: prefix ? `${prefix}/` : '',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const e = error;
|
|
29
|
+
if ((e.code === 'ENOENT' && e.syscall?.startsWith('spawn')) ||
|
|
30
|
+
e.stderr?.toString().includes('not a git repository'))
|
|
31
|
+
return undefined;
|
|
32
|
+
throw new Error(`Cannot resolve local Git excludes: ${e.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function ignoredText(original, target, paths) {
|
|
36
|
+
const start = `# >>> loadout ${target.key}`;
|
|
37
|
+
const end = `# <<< loadout ${target.key}`;
|
|
38
|
+
const lines = original.split(/(?<=\n)/);
|
|
39
|
+
const markers = lines.map((line) => line.replace(/\r?\n$/, ''));
|
|
40
|
+
const first = markers.indexOf(start), last = markers.indexOf(end);
|
|
41
|
+
if (first < 0 !== last < 0 ||
|
|
42
|
+
last < first ||
|
|
43
|
+
markers.filter((line) => line === start).length > 1 ||
|
|
44
|
+
markers.filter((line) => line === end).length > 1)
|
|
45
|
+
throw new Error('Malformed Loadout block in Git info/exclude; repair its markers before applying.');
|
|
46
|
+
const escape = (p) => p.replace(/[\\*?\[\]#! ]/g, '\\$&');
|
|
47
|
+
const patterns = [...new Set(['.loadout-personal/', ...paths])]
|
|
48
|
+
.sort()
|
|
49
|
+
.map((p) => `/${escape(target.prefix + p)}`);
|
|
50
|
+
const block = `${start}\n${patterns.join('\n')}\n${end}\n`;
|
|
51
|
+
if (first >= 0) {
|
|
52
|
+
lines.splice(first, last - first + 1, block);
|
|
53
|
+
return lines.join('');
|
|
54
|
+
}
|
|
55
|
+
return `${original}${original && !original.endsWith('\n') ? '\n' : ''}${block}`;
|
|
56
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -2,15 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { exists, safePath } from './fs.js';
|
|
5
|
-
import {
|
|
5
|
+
import { apply, planExcludes } from './storage.js';
|
|
6
6
|
export function initialize(cwd, global = fs.realpathSync(cwd) === fs.realpathSync(os.homedir())) {
|
|
7
7
|
const root = fs.realpathSync(cwd);
|
|
8
8
|
const target = safePath(root, '.loadout');
|
|
9
9
|
if (exists(target))
|
|
10
10
|
throw new Error('.loadout already exists. Refusing to replace an existing catalog.');
|
|
11
|
-
const
|
|
12
|
-
const original = exists(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
|
|
13
|
-
const updated = ignoredText(original, []); // Validate before creating anything.
|
|
11
|
+
const exclude = planExcludes(root, []); // Validate before creating anything.
|
|
14
12
|
try {
|
|
15
13
|
const starterFiles = {
|
|
16
14
|
'config.yaml': 'schemaVersion: 1\n',
|
|
@@ -42,7 +40,11 @@ and set ready: true in the kit's kit.yaml when it is ready to use.
|
|
|
42
40
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
41
|
fs.writeFileSync(file, content, { flag: 'wx' });
|
|
44
42
|
}
|
|
45
|
-
|
|
43
|
+
apply({
|
|
44
|
+
root,
|
|
45
|
+
changes: [],
|
|
46
|
+
exclude,
|
|
47
|
+
});
|
|
46
48
|
}
|
|
47
49
|
catch (error) {
|
|
48
50
|
fs.rmSync(target, { recursive: true, force: true });
|
package/dist/interactive.js
CHANGED
|
@@ -2,7 +2,6 @@ import { checkbox, confirm, select } from '@inquirer/prompts';
|
|
|
2
2
|
import { configure } from './resolve.js';
|
|
3
3
|
import { validAnswer } from './schema.js';
|
|
4
4
|
import { targetPicker } from './picker.js';
|
|
5
|
-
import { initializeTarget } from './targets.js';
|
|
6
5
|
import { availableUpdates, updateDescription } from './updates.js';
|
|
7
6
|
export async function interactive(targets, initial = 0) {
|
|
8
7
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
@@ -10,7 +9,6 @@ export async function interactive(targets, initial = 0) {
|
|
|
10
9
|
const chosen = await targetPicker({
|
|
11
10
|
targets,
|
|
12
11
|
initial,
|
|
13
|
-
initialize: initializeTarget,
|
|
14
12
|
});
|
|
15
13
|
const configured = [];
|
|
16
14
|
for (const { target, state } of chosen) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stateDirectory(root: string): string;
|
package/dist/picker.d.ts
CHANGED
|
@@ -11,7 +11,6 @@ export type TargetPickerConfig = {
|
|
|
11
11
|
initial?: number;
|
|
12
12
|
columns?: number;
|
|
13
13
|
rows?: number;
|
|
14
|
-
initialize?: (target: Target) => Target;
|
|
15
14
|
};
|
|
16
15
|
export type TargetSelection = {
|
|
17
16
|
target: Target;
|
|
@@ -22,7 +21,6 @@ declare const renderPicker: import("@inquirer/type").Prompt<TargetSelection[], {
|
|
|
22
21
|
initial?: number;
|
|
23
22
|
columns?: number;
|
|
24
23
|
rows?: number;
|
|
25
|
-
initialize?: (target: Target) => Target;
|
|
26
24
|
} & TargetPickerConfig>;
|
|
27
25
|
export declare function targetPicker(config: TargetPickerConfig, context?: Parameters<typeof renderPicker>[1]): Promise<TargetSelection[]>;
|
|
28
26
|
export declare function kitPicker(config: PickerConfig, context?: Parameters<typeof targetPicker>[1]): Promise<string[]>;
|
package/dist/picker.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createPrompt, useEffect, useKeypress, useState, isEnterKey, isSpaceKey, } from '@inquirer/core';
|
|
2
2
|
import { stripVTControlCharacters, styleText } from 'node:util';
|
|
3
|
-
import path from 'node:path';
|
|
4
3
|
import stringWidth from 'string-width';
|
|
5
4
|
import { resolveKits, reasons } from './resolve.js';
|
|
6
5
|
import { kitSource } from './schema.js';
|
|
@@ -35,7 +34,10 @@ function wordmark(width, rows) {
|
|
|
35
34
|
];
|
|
36
35
|
}
|
|
37
36
|
const repositorySections = ['Kits', 'Browse', 'Installed'];
|
|
38
|
-
const providerFor = (kit) => kit.origin === 'bundled'
|
|
37
|
+
const providerFor = (kit) => kit.origin === 'bundled'
|
|
38
|
+
? 'loadout'
|
|
39
|
+
: (kit.external?.repo ??
|
|
40
|
+
(kit.origin === 'personal' ? 'Personal' : undefined));
|
|
39
41
|
const providerPrefixes = {
|
|
40
42
|
loadout: 'loadout-',
|
|
41
43
|
'mattpocock/skills': 'matt-pocock-',
|
|
@@ -52,7 +54,6 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
52
54
|
const [visited, setVisited] = useState([config.initial ?? 0]);
|
|
53
55
|
const [selections, setSelections] = useState(config.targets.map((target) => (target.state?.selected ?? []).filter((id) => target.catalog?.kits.has(id) &&
|
|
54
56
|
target.catalog.kits.get(id)?.ready !== false)));
|
|
55
|
-
const [pending, setPending] = useState(undefined);
|
|
56
57
|
const [pendingSwitch, setPendingSwitch] = useState(undefined);
|
|
57
58
|
const [notice, setNotice] = useState('');
|
|
58
59
|
const target = targets[targetIndex];
|
|
@@ -67,7 +68,10 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
67
68
|
const hasSelectionChanges = JSON.stringify([...selected].sort()) !==
|
|
68
69
|
JSON.stringify([...(target.state?.selected ?? [])].sort());
|
|
69
70
|
const setSelected = (value) => setSelections(selections.map((ids, index) => (index === targetIndex ? value : ids)));
|
|
70
|
-
const [section, setSection] = useState(target.global
|
|
71
|
+
const [section, setSection] = useState(target.global ||
|
|
72
|
+
![...catalog.kits.values()].some((kit) => !providerFor(kit))
|
|
73
|
+
? 'Browse'
|
|
74
|
+
: 'Kits');
|
|
71
75
|
const [scopeFocused, setScopeFocused] = useState(false);
|
|
72
76
|
const [provider, setProvider] = useState(undefined);
|
|
73
77
|
const [query, setQuery] = useState('');
|
|
@@ -132,13 +136,15 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
132
136
|
const origins = new Set(kits.map((kit) => kit.origin));
|
|
133
137
|
const description = id === 'loadout'
|
|
134
138
|
? 'Included with Loadout'
|
|
135
|
-
:
|
|
136
|
-
?
|
|
137
|
-
: origins.
|
|
138
|
-
?
|
|
139
|
-
: origins.
|
|
140
|
-
? 'Curated
|
|
141
|
-
: '
|
|
139
|
+
: id === 'Personal'
|
|
140
|
+
? 'Your personal kits'
|
|
141
|
+
: origins.has('curated') && providerDescriptions[id]
|
|
142
|
+
? providerDescriptions[id]
|
|
143
|
+
: origins.size > 1
|
|
144
|
+
? 'Curated and repository sources'
|
|
145
|
+
: origins.has('curated')
|
|
146
|
+
? 'Curated kits'
|
|
147
|
+
: 'Repository sources';
|
|
142
148
|
return [
|
|
143
149
|
{
|
|
144
150
|
id,
|
|
@@ -187,7 +193,10 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
187
193
|
const switchTarget = (index) => {
|
|
188
194
|
setScopeFocused(false);
|
|
189
195
|
setTargetIndex(index);
|
|
190
|
-
setSection(targets[index].global
|
|
196
|
+
setSection(targets[index].global ||
|
|
197
|
+
![...targets[index].catalog.kits.values()].some((kit) => !providerFor(kit))
|
|
198
|
+
? 'Browse'
|
|
199
|
+
: 'Kits');
|
|
191
200
|
setVisited([...new Set([...visited, index])]);
|
|
192
201
|
setProvider(undefined);
|
|
193
202
|
setNotice('');
|
|
@@ -206,40 +215,14 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
206
215
|
}
|
|
207
216
|
return;
|
|
208
217
|
}
|
|
209
|
-
if (pending !== undefined) {
|
|
210
|
-
if (key.name === 'y' && config.initialize) {
|
|
211
|
-
try {
|
|
212
|
-
const initialized = config.initialize(targets[pending]);
|
|
213
|
-
if (!initialized.catalog)
|
|
214
|
-
throw new Error(initialized.error ??
|
|
215
|
-
'Initialization did not produce a catalog.');
|
|
216
|
-
setTargets(targets.map((item, index) => index === pending ? initialized : item));
|
|
217
|
-
setSelections(selections.map((ids, index) => index === pending ? (initialized.state?.selected ?? []) : ids));
|
|
218
|
-
switchTarget(pending);
|
|
219
|
-
}
|
|
220
|
-
catch (error) {
|
|
221
|
-
setNotice(error.message);
|
|
222
|
-
}
|
|
223
|
-
setPending(undefined);
|
|
224
|
-
}
|
|
225
|
-
else if (key.name === 'n' ||
|
|
226
|
-
key.name === 'escape' ||
|
|
227
|
-
isEnterKey(key)) {
|
|
228
|
-
setPending(undefined);
|
|
229
|
-
}
|
|
230
|
-
restoreInput();
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
218
|
if (scopeFocused && (isEnterKey(key) || isSpaceKey(key))) {
|
|
234
219
|
setScopeFocused(false);
|
|
235
220
|
const next = (targetIndex + 1) % targets.length;
|
|
236
221
|
const other = targets[next];
|
|
237
222
|
if (other.error)
|
|
238
223
|
setNotice(other.error);
|
|
239
|
-
else if (!other.catalog)
|
|
240
|
-
|
|
241
|
-
reset();
|
|
242
|
-
}
|
|
224
|
+
else if (!other.catalog)
|
|
225
|
+
setNotice('Cannot load this location.');
|
|
243
226
|
else if (hasSelectionChanges) {
|
|
244
227
|
setPendingSwitch(next);
|
|
245
228
|
}
|
|
@@ -348,27 +331,6 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
348
331
|
` ${accent('[Enter/Esc]')} Stay ${accent('[y]')} Switch`,
|
|
349
332
|
'\u001b[?25l',
|
|
350
333
|
].join('\n');
|
|
351
|
-
if (pending !== undefined) {
|
|
352
|
-
const destination = targets[pending];
|
|
353
|
-
const pathWidth = width - 9;
|
|
354
|
-
const catalogPath = path.join(destination.root, '.loadout');
|
|
355
|
-
const displayPath = stringWidth(clean(catalogPath)) <= pathWidth
|
|
356
|
-
? clean(catalogPath)
|
|
357
|
-
: destination.global
|
|
358
|
-
? '~/.loadout'
|
|
359
|
-
: `${fit(destination.root, pathWidth - 9)}/.loadout`;
|
|
360
|
-
return [
|
|
361
|
-
...header,
|
|
362
|
-
...scopeLines,
|
|
363
|
-
'',
|
|
364
|
-
` ${bold(fit(`Set up ${destination.label} and switch?`, width - 2))}`,
|
|
365
|
-
` ${fit(`Create ${displayPath}`, width - 2)}`,
|
|
366
|
-
...selectionWarning,
|
|
367
|
-
'',
|
|
368
|
-
` ${accent('[Enter/Esc]')} Cancel ${accent('[y]')} Set up`,
|
|
369
|
-
'\u001b[?25l',
|
|
370
|
-
].join('\n');
|
|
371
|
-
}
|
|
372
334
|
if (finished)
|
|
373
335
|
return [
|
|
374
336
|
...header,
|
|
@@ -384,7 +346,7 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
384
346
|
const detailed = height >= 20;
|
|
385
347
|
const hints = scopeFocused
|
|
386
348
|
? [
|
|
387
|
-
`space/enter ${other?.error
|
|
349
|
+
`space/enter ${other?.error || !other?.catalog ? 'details' : 'switch'}`,
|
|
388
350
|
'←→/tab move',
|
|
389
351
|
'esc back',
|
|
390
352
|
]
|
|
@@ -406,9 +368,9 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
406
368
|
}
|
|
407
369
|
const help = helpLines.map((line) => ` ${muted(line)}`);
|
|
408
370
|
if (scopeFocused && other) {
|
|
409
|
-
const action = other.error
|
|
371
|
+
const action = other.error || !other.catalog
|
|
410
372
|
? `${other.label} is unavailable`
|
|
411
|
-
:
|
|
373
|
+
: `Switch to ${other.label}`;
|
|
412
374
|
return [
|
|
413
375
|
...header,
|
|
414
376
|
...scopeLines,
|
package/dist/render.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ export type FileContent = {
|
|
|
6
6
|
export type Rendered = {
|
|
7
7
|
files: Map<string, FileContent>;
|
|
8
8
|
skillRoots: Set<string>;
|
|
9
|
+
instructionGroups: {
|
|
10
|
+
paths: string[];
|
|
11
|
+
kits: string[];
|
|
12
|
+
}[];
|
|
13
|
+
skillKits: Set<string>;
|
|
9
14
|
external?: {
|
|
10
15
|
before?: Buffer;
|
|
11
16
|
content: Buffer;
|
package/dist/render.js
CHANGED
|
@@ -1,27 +1,43 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { agents } from './schema.js';
|
|
4
|
-
import { safePath, walk, portableMode } from './fs.js';
|
|
4
|
+
import { safePath, walk, portableMode, exists } from './fs.js';
|
|
5
5
|
import { resolveKits } from './resolve.js';
|
|
6
6
|
export function render(catalog, state) {
|
|
7
7
|
const files = new Map();
|
|
8
8
|
const skillRoots = new Set();
|
|
9
|
+
const skillKits = new Set();
|
|
10
|
+
const instructionGroups = [];
|
|
11
|
+
const instructionKits = new Map();
|
|
9
12
|
const sections = new Map();
|
|
10
13
|
for (const id of resolveKits(catalog, state.selected)) {
|
|
11
14
|
const kit = catalog.kits.get(id);
|
|
15
|
+
if (kit.external)
|
|
16
|
+
skillKits.add(id);
|
|
12
17
|
for (const output of kit.outputs) {
|
|
13
18
|
if (output.when &&
|
|
14
19
|
state.answers[id]?.[output.when.answer] !== output.when.equals)
|
|
15
20
|
continue;
|
|
16
21
|
const source = safePath(kit.directory, output.source);
|
|
17
22
|
if (output.type === 'instructions') {
|
|
23
|
+
if (catalog.global && output.scope !== '.')
|
|
24
|
+
throw new Error(`${kit.id}: global instructions must use scope: .`);
|
|
25
|
+
if (output.scope !== '.') {
|
|
26
|
+
const scope = safePath(catalog.root, output.scope);
|
|
27
|
+
if (!exists(scope) || !fs.statSync(scope).isDirectory())
|
|
28
|
+
throw new Error(`${kit.id}: scope directory does not exist: ${output.scope}`);
|
|
29
|
+
}
|
|
18
30
|
const section = fs.readFileSync(source, 'utf8').trim();
|
|
31
|
+
const kits = instructionKits.get(output.scope) ?? new Set();
|
|
32
|
+
kits.add(id);
|
|
33
|
+
instructionKits.set(output.scope, kits);
|
|
19
34
|
sections.set(output.scope, [
|
|
20
35
|
...(sections.get(output.scope) ?? []),
|
|
21
36
|
section,
|
|
22
37
|
]);
|
|
23
38
|
}
|
|
24
39
|
else {
|
|
40
|
+
skillKits.add(id);
|
|
25
41
|
for (const agent of agents) {
|
|
26
42
|
const destination = `${agent === 'codex' ? '.agents' : '.claude'}/skills/${path.basename(source)}`;
|
|
27
43
|
if (skillRoots.has(destination))
|
|
@@ -47,6 +63,10 @@ export function render(catalog, state) {
|
|
|
47
63
|
: path.posix.join(scope, 'CLAUDE.md');
|
|
48
64
|
if (files.has(instructions) || files.has(claude))
|
|
49
65
|
throw new Error(`Output collision: ${scope}`);
|
|
66
|
+
instructionGroups.push({
|
|
67
|
+
paths: [instructions, claude],
|
|
68
|
+
kits: [...instructionKits.get(scope)],
|
|
69
|
+
});
|
|
50
70
|
files.set(instructions, {
|
|
51
71
|
content: Buffer.from(`${content.join('\n\n')}\n`),
|
|
52
72
|
mode: 0o644,
|
|
@@ -60,5 +80,7 @@ export function render(catalog, state) {
|
|
|
60
80
|
return {
|
|
61
81
|
files: new Map([...files].sort(([a], [b]) => a.localeCompare(b))),
|
|
62
82
|
skillRoots,
|
|
83
|
+
instructionGroups,
|
|
84
|
+
skillKits,
|
|
63
85
|
};
|
|
64
86
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -97,7 +97,7 @@ export type Kit = z.infer<typeof kitSchema> & {
|
|
|
97
97
|
directory: string;
|
|
98
98
|
external?: ExternalSource;
|
|
99
99
|
pinned?: ExternalSource;
|
|
100
|
-
origin?: 'curated' | 'external' | 'bundled';
|
|
100
|
+
origin?: 'curated' | 'external' | 'bundled' | 'personal';
|
|
101
101
|
};
|
|
102
102
|
export type Catalog = {
|
|
103
103
|
root: string;
|
package/dist/schema.js
CHANGED
|
@@ -14,7 +14,14 @@ export const relativePath = z
|
|
|
14
14
|
.every((s) => s !== '..' && s !== '.' && s !== '' && !s.endsWith(' '))), 'Use a relative path without traversal, glob characters, or backslashes');
|
|
15
15
|
export const scopeSchema = relativePath.refine((p) => !p
|
|
16
16
|
.split('/')
|
|
17
|
-
.some((s) => [
|
|
17
|
+
.some((s) => [
|
|
18
|
+
'.git',
|
|
19
|
+
'.loadout',
|
|
20
|
+
'.loadout-personal',
|
|
21
|
+
'.agents',
|
|
22
|
+
'.claude',
|
|
23
|
+
'.codex',
|
|
24
|
+
].includes(s)), 'Scope must be a repository directory outside configuration directories');
|
|
18
25
|
const condition = z
|
|
19
26
|
.object({ answer: idSchema, equals: z.union([z.boolean(), z.string()]) })
|
|
20
27
|
.strict();
|
|
@@ -128,7 +135,8 @@ export function parse(schema, value, label) {
|
|
|
128
135
|
export function kitSource(kit) {
|
|
129
136
|
return kit.origin === 'bundled'
|
|
130
137
|
? 'loadout'
|
|
131
|
-
: ((kit.pinned ?? kit.external)?.repo ??
|
|
138
|
+
: ((kit.pinned ?? kit.external)?.repo ??
|
|
139
|
+
(kit.origin === 'personal' ? 'Personal' : 'Repository'));
|
|
132
140
|
}
|
|
133
141
|
export function sameSource(a, b) {
|
|
134
142
|
return (a.repo === b.repo &&
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type IgnoreTarget } from './ignore.js';
|
|
1
2
|
import { type Catalog, type State } from './schema.js';
|
|
2
3
|
import { type FileContent, type Rendered } from './render.js';
|
|
3
4
|
export type Change = {
|
|
@@ -10,6 +11,13 @@ export type Plan = {
|
|
|
10
11
|
root: string;
|
|
11
12
|
changes: Change[];
|
|
12
13
|
adopted?: string[];
|
|
14
|
+
skippedInstructions?: {
|
|
15
|
+
paths: string[];
|
|
16
|
+
kits: string[];
|
|
17
|
+
reason: string;
|
|
18
|
+
}[];
|
|
19
|
+
kitsWithoutOutputs?: string[];
|
|
20
|
+
exclude?: ExcludePlan;
|
|
13
21
|
guard?: {
|
|
14
22
|
untracked: string[];
|
|
15
23
|
directories: {
|
|
@@ -19,9 +27,16 @@ export type Plan = {
|
|
|
19
27
|
};
|
|
20
28
|
};
|
|
21
29
|
export declare function loadState(catalog: Catalog): State;
|
|
22
|
-
|
|
30
|
+
type ExcludePlan = {
|
|
31
|
+
target: IgnoreTarget;
|
|
32
|
+
paths: string[];
|
|
33
|
+
change: Change;
|
|
34
|
+
};
|
|
35
|
+
export declare function planExcludes(root: string, paths: string[]): ExcludePlan | undefined;
|
|
36
|
+
export declare function hasChanges(plan: Plan): boolean;
|
|
23
37
|
export declare function plan(catalog: Catalog, state: State, rendered: Rendered, options?: {
|
|
24
38
|
adopt?: boolean;
|
|
25
39
|
}): Plan;
|
|
26
40
|
export declare function apply(plan: Plan): number;
|
|
27
41
|
export declare function applyAll(plans: Plan[]): number;
|
|
42
|
+
export {};
|
package/dist/storage.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { isOutput } from './output-path.js';
|
|
3
|
+
import { ignoredText, ignoreTarget } from './ignore.js';
|
|
3
4
|
import { prepareAdoption } from './adoption.js';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
7
|
import { execFileSync } from 'node:child_process';
|
|
7
8
|
import { exists, json, readOptional, safePath, walk, portableMode, } from './fs.js';
|
|
8
9
|
import { parse, ownedSchema, stateSchema, } from './schema.js';
|
|
9
|
-
const start = '# >>> loadout';
|
|
10
|
-
const end = '# <<< loadout';
|
|
11
10
|
const hash = (content) => createHash('sha256').update(content).digest('hex');
|
|
12
11
|
export function loadState(catalog) {
|
|
13
|
-
const raw = readOptional(catalog.root, '.loadout/local.json');
|
|
12
|
+
const raw = readOptional(catalog.root, '.loadout-personal/local.json');
|
|
14
13
|
return raw
|
|
15
|
-
? parse(stateSchema, JSON.parse(raw.toString()), '.loadout/local.json')
|
|
14
|
+
? parse(stateSchema, JSON.parse(raw.toString()), '.loadout-personal/local.json')
|
|
16
15
|
: { schemaVersion: 1, selected: [], answers: {} };
|
|
17
16
|
}
|
|
18
17
|
function snapshot(root, relative) {
|
|
@@ -29,33 +28,29 @@ function equal(a, b) {
|
|
|
29
28
|
? a === b
|
|
30
29
|
: a.mode === b.mode && a.content.equals(b.content);
|
|
31
30
|
}
|
|
32
|
-
export function
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
]
|
|
56
|
-
.sort()
|
|
57
|
-
.map((p) => `/${escape(p)}`)
|
|
58
|
-
.join('\n')}\n${end}\n`;
|
|
31
|
+
export function planExcludes(root, paths) {
|
|
32
|
+
const target = ignoreTarget(root);
|
|
33
|
+
if (!target)
|
|
34
|
+
return undefined;
|
|
35
|
+
const before = snapshot(target.root, 'info/exclude');
|
|
36
|
+
const after = {
|
|
37
|
+
content: Buffer.from(ignoredText(before?.content.toString() ?? '', target, paths)),
|
|
38
|
+
mode: before?.mode ?? 0o644,
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
target,
|
|
42
|
+
paths,
|
|
43
|
+
change: {
|
|
44
|
+
path: 'info/exclude',
|
|
45
|
+
before,
|
|
46
|
+
after,
|
|
47
|
+
kind: equal(before, after) ? 'unchanged' : before ? 'update' : 'create',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function hasChanges(plan) {
|
|
52
|
+
return (plan.changes.some((change) => change.kind !== 'unchanged') ||
|
|
53
|
+
!!(plan.exclude && plan.exclude.change.kind !== 'unchanged'));
|
|
59
54
|
}
|
|
60
55
|
function trackedFiles(root) {
|
|
61
56
|
try {
|
|
@@ -76,27 +71,43 @@ function trackedFiles(root) {
|
|
|
76
71
|
}
|
|
77
72
|
export function plan(catalog, state, rendered, options = {}) {
|
|
78
73
|
const root = catalog.root;
|
|
79
|
-
const raw = readOptional(root, '.loadout/generated.json');
|
|
74
|
+
const raw = readOptional(root, '.loadout-personal/generated.json');
|
|
80
75
|
const owned = raw
|
|
81
|
-
? parse(ownedSchema, JSON.parse(raw.toString()), '.loadout/generated.json')
|
|
82
|
-
.files
|
|
76
|
+
? parse(ownedSchema, JSON.parse(raw.toString()), '.loadout-personal/generated.json').files
|
|
83
77
|
: {};
|
|
78
|
+
const tracked = trackedFiles(root);
|
|
79
|
+
const skippedInstructions = [];
|
|
80
|
+
const retainedKits = new Set(rendered.skillKits);
|
|
81
|
+
rendered = { ...rendered, files: new Map(rendered.files) };
|
|
82
|
+
for (const group of rendered.instructionGroups) {
|
|
83
|
+
// Existing ownership still requires the usual edit/deletion safeguards.
|
|
84
|
+
const conflict = group.paths.some((file) => Object.hasOwn(owned, file))
|
|
85
|
+
? undefined
|
|
86
|
+
: group.paths.find((file) => tracked.has(file) ||
|
|
87
|
+
(!options.adopt && exists(safePath(root, file))));
|
|
88
|
+
if (!conflict) {
|
|
89
|
+
for (const id of group.kits)
|
|
90
|
+
retainedKits.add(id);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
skippedInstructions.push({
|
|
94
|
+
...group,
|
|
95
|
+
reason: tracked.has(conflict)
|
|
96
|
+
? `${conflict} is tracked by Git`
|
|
97
|
+
: `${conflict} already exists and is not managed by Loadout`,
|
|
98
|
+
});
|
|
99
|
+
// CLAUDE.md imports AGENTS.md, so skip the whole scope together.
|
|
100
|
+
for (const file of group.paths)
|
|
101
|
+
rendered.files.delete(file);
|
|
102
|
+
}
|
|
103
|
+
const kitsWithoutOutputs = [
|
|
104
|
+
...new Set(skippedInstructions.flatMap((group) => group.kits)),
|
|
105
|
+
].filter((id) => !retainedKits.has(id));
|
|
84
106
|
const adoption = prepareAdoption(root, rendered, owned, !!catalog.global, !!options.adopt);
|
|
85
107
|
rendered = { ...rendered, files: adoption.files };
|
|
86
108
|
const paths = [
|
|
87
109
|
...new Set([...Object.keys(owned), ...rendered.files.keys()]),
|
|
88
110
|
].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
111
|
const changes = [...adoption.beforeOutputs];
|
|
101
112
|
for (const relative of paths) {
|
|
102
113
|
if (!isOutput(relative, catalog.global))
|
|
@@ -137,22 +148,16 @@ export function plan(catalog, state, rendered, options = {}) {
|
|
|
137
148
|
.filter(([p]) => !adoption.released.has(p))
|
|
138
149
|
.map(([p, f]) => [p, { hash: hash(f.content), mode: f.mode }]));
|
|
139
150
|
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
|
-
],
|
|
151
|
+
['.loadout-personal/local.json', json(state)],
|
|
152
|
+
['.loadout-personal/generated.json', json({ schemaVersion: 1, files })],
|
|
148
153
|
]);
|
|
149
154
|
if (rendered.external) {
|
|
150
|
-
const current = readOptional(root, '.loadout/external.json');
|
|
155
|
+
const current = readOptional(root, '.loadout-personal/external.json');
|
|
151
156
|
if (current === undefined
|
|
152
157
|
? rendered.external.before !== undefined
|
|
153
158
|
: !rendered.external.before?.equals(current))
|
|
154
159
|
throw new Error('External snapshots changed while preparing the preview. Run the command again.');
|
|
155
|
-
metadata.set('.loadout/external.json', rendered.external.content);
|
|
160
|
+
metadata.set('.loadout-personal/external.json', rendered.external.content);
|
|
156
161
|
}
|
|
157
162
|
changes.push(...adoption.afterOutputs);
|
|
158
163
|
for (const [relative, content] of metadata) {
|
|
@@ -164,14 +169,18 @@ export function plan(catalog, state, rendered, options = {}) {
|
|
|
164
169
|
kind: equal(before, after) ? 'unchanged' : before ? 'update' : 'create',
|
|
165
170
|
});
|
|
166
171
|
}
|
|
172
|
+
for (const change of changes)
|
|
173
|
+
if (tracked.has(change.path))
|
|
174
|
+
throw new Error(`Refusing to manage Git-tracked file: ${change.path}`);
|
|
167
175
|
return {
|
|
168
176
|
root,
|
|
169
177
|
changes,
|
|
170
178
|
adopted: adoption.adopted,
|
|
179
|
+
skippedInstructions,
|
|
180
|
+
kitsWithoutOutputs,
|
|
181
|
+
exclude: planExcludes(root, Object.keys(files)),
|
|
171
182
|
guard: {
|
|
172
|
-
untracked: changes
|
|
173
|
-
.filter((change) => change.path !== '.gitignore')
|
|
174
|
-
.map((change) => change.path),
|
|
183
|
+
untracked: changes.map((change) => change.path),
|
|
175
184
|
directories: [...rendered.skillRoots]
|
|
176
185
|
.filter((skill) => adoption.adopted.some((file) => file.startsWith(`${skill}/`)))
|
|
177
186
|
.map((skill) => ({
|
|
@@ -202,7 +211,9 @@ function prune(root, relative) {
|
|
|
202
211
|
// Repository scopes and agent configuration roots may predate Loadout.
|
|
203
212
|
// Only prune empty skill directories and internal adoption backups.
|
|
204
213
|
const boundary = /^(\.(?:agents|claude)\/skills)\//.exec(relative)?.[1] ??
|
|
205
|
-
(relative.startsWith('.loadout/adopted/')
|
|
214
|
+
(relative.startsWith('.loadout-personal/adopted/')
|
|
215
|
+
? '.loadout-personal'
|
|
216
|
+
: undefined);
|
|
206
217
|
if (!boundary)
|
|
207
218
|
return;
|
|
208
219
|
const stop = safePath(root, boundary);
|
|
@@ -223,22 +234,77 @@ export function apply(plan) {
|
|
|
223
234
|
export function applyAll(plans) {
|
|
224
235
|
if (new Set(plans.map((plan) => plan.root)).size !== plans.length)
|
|
225
236
|
throw new Error('Cannot apply multiple plans for the same location.');
|
|
237
|
+
// Several catalogs/worktrees can share one excludes file. Merge their blocks
|
|
238
|
+
// into one transactional write, retaining all other catalogs' rules.
|
|
239
|
+
const excludes = new Map();
|
|
240
|
+
for (const plan of plans) {
|
|
241
|
+
if (!plan.exclude)
|
|
242
|
+
continue;
|
|
243
|
+
const { target, paths, change } = plan.exclude;
|
|
244
|
+
const previous = excludes.get(target.root);
|
|
245
|
+
if (!previous) {
|
|
246
|
+
excludes.set(target.root, change);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!equal(previous.before, change.before))
|
|
250
|
+
throw new Error('Git excludes changed between previews. Run the command again.');
|
|
251
|
+
const after = {
|
|
252
|
+
...change.after,
|
|
253
|
+
content: Buffer.from(ignoredText(previous.after.content.toString(), target, paths)),
|
|
254
|
+
};
|
|
255
|
+
excludes.set(target.root, {
|
|
256
|
+
...previous,
|
|
257
|
+
after,
|
|
258
|
+
kind: equal(previous.before, after)
|
|
259
|
+
? 'unchanged'
|
|
260
|
+
: previous.before
|
|
261
|
+
? 'update'
|
|
262
|
+
: 'create',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const writes = [
|
|
266
|
+
...[...excludes].map(([root, change]) => ({ root, change })),
|
|
267
|
+
...plans.flatMap((plan) => plan.changes.map((change) => ({
|
|
268
|
+
root: plan.root,
|
|
269
|
+
change,
|
|
270
|
+
}))),
|
|
271
|
+
];
|
|
226
272
|
const locks = [];
|
|
227
273
|
const written = [];
|
|
228
274
|
try {
|
|
229
275
|
for (const plan of [...plans].sort((a, b) => a.root.localeCompare(b.root))) {
|
|
230
|
-
const lock = safePath(plan.root, '.loadout/apply.lock');
|
|
276
|
+
const lock = safePath(plan.root, '.loadout-personal/apply.lock');
|
|
277
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
278
|
+
try {
|
|
279
|
+
fs.mkdirSync(lock);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error.code === 'EEXIST')
|
|
283
|
+
throw new Error(`Another apply is running (or a previous process stopped). Remove ${lock} only after confirming no Loadout process is running.`);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
locks.push(lock);
|
|
287
|
+
}
|
|
288
|
+
// These locks also serialize applies from different linked worktrees.
|
|
289
|
+
for (const root of [...excludes.keys()].sort()) {
|
|
290
|
+
const lock = safePath(root, 'info/exclude.loadout.lock');
|
|
291
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
231
292
|
try {
|
|
232
293
|
fs.mkdirSync(lock);
|
|
233
294
|
}
|
|
234
295
|
catch (error) {
|
|
235
296
|
if (error.code === 'EEXIST')
|
|
236
|
-
throw new Error(
|
|
297
|
+
throw new Error(`Another apply is using Git excludes (or a previous process stopped). Remove ${lock} only after confirming no Loadout process is running.`);
|
|
237
298
|
throw error;
|
|
238
299
|
}
|
|
239
300
|
locks.push(lock);
|
|
240
301
|
}
|
|
241
302
|
// Recheck every location before writing to any of them.
|
|
303
|
+
for (const plan of plans) {
|
|
304
|
+
const current = ignoreTarget(plan.root);
|
|
305
|
+
if (JSON.stringify(current) !== JSON.stringify(plan.exclude?.target))
|
|
306
|
+
throw new Error('Git exclude location changed since preview. Run the command again.');
|
|
307
|
+
}
|
|
242
308
|
for (const plan of plans) {
|
|
243
309
|
if (!plan.guard)
|
|
244
310
|
continue;
|
|
@@ -252,20 +318,17 @@ export function applyAll(plans) {
|
|
|
252
318
|
throw new Error(`Skill directory changed since preview: ${directory.path}`);
|
|
253
319
|
}
|
|
254
320
|
}
|
|
255
|
-
for (const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
fs.unlinkSync(safePath(plan.root, change.path));
|
|
267
|
-
written.push({ root: plan.root, change });
|
|
268
|
-
}
|
|
321
|
+
for (const { root, change } of writes)
|
|
322
|
+
if (!equal(snapshot(root, change.path), change.before))
|
|
323
|
+
throw new Error(`File changed since preview: ${change.path}. Run the command again.`);
|
|
324
|
+
for (const { root, change } of writes) {
|
|
325
|
+
if (change.kind === 'unchanged')
|
|
326
|
+
continue;
|
|
327
|
+
if (change.after)
|
|
328
|
+
writeAtomic(root, change.path, change.after);
|
|
329
|
+
else
|
|
330
|
+
fs.unlinkSync(safePath(root, change.path));
|
|
331
|
+
written.push({ root, change });
|
|
269
332
|
}
|
|
270
333
|
}
|
|
271
334
|
catch (error) {
|
package/dist/targets.d.ts
CHANGED
package/dist/targets.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { loadCatalog } from './catalog.js';
|
|
2
2
|
import { readExternal } from './external.js';
|
|
3
|
-
import { exists, safePath } from './fs.js';
|
|
4
|
-
import { initialize } from './init.js';
|
|
5
3
|
import { loadState } from './storage.js';
|
|
6
4
|
export function loadTarget(root, global) {
|
|
7
5
|
const target = {
|
|
@@ -10,10 +8,8 @@ export function loadTarget(root, global) {
|
|
|
10
8
|
label: global ? 'Global' : 'Repository',
|
|
11
9
|
};
|
|
12
10
|
try {
|
|
13
|
-
if (!exists(safePath(root, '.loadout/config.yaml')))
|
|
14
|
-
return target;
|
|
15
11
|
const catalog = loadCatalog(root, global);
|
|
16
|
-
const { store } = readExternal(root);
|
|
12
|
+
const { store } = readExternal(catalog.root);
|
|
17
13
|
for (const [id, snapshot] of Object.entries(store.kits)) {
|
|
18
14
|
const kit = catalog.kits.get(id);
|
|
19
15
|
if (kit?.external)
|
|
@@ -25,7 +21,3 @@ export function loadTarget(root, global) {
|
|
|
25
21
|
return { ...target, error: error.message };
|
|
26
22
|
}
|
|
27
23
|
}
|
|
28
|
-
export function initializeTarget(target) {
|
|
29
|
-
initialize(target.root, target.global);
|
|
30
|
-
return loadTarget(target.root, target.global);
|
|
31
|
-
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loadout-write-kit
|
|
3
|
-
description: Create or edit Loadout kits
|
|
3
|
+
description: Create or edit private or shared Loadout kits.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Adapt this example; drop unused questions, outputs, and files.
|
|
6
|
+
Use `.loadout-personal/kits/` for private kits, `.loadout/kits/` for shared kits, or `~/.loadout/kits/` for kits you can enable in any repository. Adapt this example; drop unused questions, outputs, and files.
|
|
7
7
|
|
|
8
|
-
`.loadout/kits/review/kit.yaml`:
|
|
8
|
+
`.loadout-personal/kits/review/kit.yaml`:
|
|
9
9
|
|
|
10
10
|
```yaml
|
|
11
11
|
schemaVersion: 1
|
|
@@ -28,7 +28,7 @@ outputs:
|
|
|
28
28
|
when: { answer: detail, equals: detailed }
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
`.loadout/kits/review/skills/review/SKILL.md`:
|
|
31
|
+
`.loadout-personal/kits/review/skills/review/SKILL.md`:
|
|
32
32
|
|
|
33
33
|
```markdown
|
|
34
34
|
---
|
|
@@ -52,4 +52,4 @@ loadout enable review --answer review.diagrams=true --answer review.detail=detai
|
|
|
52
52
|
loadout disable review
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
Drop `--dry-run --diff` to apply, or select via `loadout` →
|
|
55
|
+
Drop `--dry-run --diff` to apply, or select via `loadout` → Browse → Personal → Continue.
|