@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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # Loadout
2
2
 
3
- Share a collection of agent skills and instructions with your team. Everyone in the repository picks the kits they want, keeping the full collection available without adding it all to their agent's context.
3
+ Choose agent skills and instructions for yourself or your team. Loadout configures Codex and Claude Code.
4
4
 
5
- Kit definitions live in the repository. Each developer's selections and generated files stay local and ignored by Git. Loadout configures both Codex and Claude Code.
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+**. In a repository using Loadout:
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
- ## Add it to your repository
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')) || path.dirname(root) === root)
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
- throw new Error('No .loadout/config.yaml found in this repository. Run loadout init at the repository root.');
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, root, global) {
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 config = parse(configSchema, readYaml(root, '.loadout/config.yaml'), '.loadout/config.yaml');
87
- const directory = safePath(root, '.loadout/kits');
88
- const kits = new Map();
89
- for (const folder of exists(directory)
90
- ? fs.readdirSync(directory).sort()
91
- : []) {
92
- const dir = safePath(directory, folder);
93
- if (!fs.statSync(dir).isDirectory())
94
- continue;
95
- const manifest = `.loadout/kits/${folder}/kit.yaml`;
96
- const kit = {
97
- ...parse(kitSchema, readYaml(root, manifest), manifest),
98
- directory: dir,
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
- if (kits.has(kit.id))
101
- throw new Error(`Duplicate kit ID: ${kit.id}`);
102
- validateKit(kit, root, global);
103
- kits.set(kit.id, kit);
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, root, global);
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', 'use the Loadout catalog in your home directory')
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
- const changed = result.changes.filter((c) => c.kind !== 'unchanged' && c.path !== '.gitignore');
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.changes.some((c) => c.kind !== 'unchanged')
65
- ? 'Ignore rules will be refreshed.'
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((result) => result.changes.some((c) => c.kind !== 'unchanged')))
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 a starter catalog and open setup in a terminal')
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 .loadout/kits/<name>/kit.yaml.');
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
  }
@@ -0,0 +1,7 @@
1
+ export type IgnoreTarget = {
2
+ root: string;
3
+ key: string;
4
+ prefix: string;
5
+ };
6
+ export declare function ignoreTarget(root: string): IgnoreTarget | undefined;
7
+ export declare function ignoredText(original: string, target: IgnoreTarget, paths: string[]): string;
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 { ignoredText } from './storage.js';
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 ignore = safePath(root, '.gitignore');
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
- fs.writeFileSync(ignore, updated);
43
+ apply({
44
+ root,
45
+ changes: [],
46
+ exclude,
47
+ });
46
48
  }
47
49
  catch (error) {
48
50
  fs.rmSync(target, { recursive: true, force: true });
@@ -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;
@@ -0,0 +1,4 @@
1
+ import { safePath } from './fs.js';
2
+ export function stateDirectory(root) {
3
+ return safePath(root, '.loadout-personal');
4
+ }
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' ? 'loadout' : kit.external?.repo;
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 ? 'Browse' : 'Kits');
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
- : origins.has('curated') && providerDescriptions[id]
136
- ? providerDescriptions[id]
137
- : origins.size > 1
138
- ? 'Curated and repository sources'
139
- : origins.has('curated')
140
- ? 'Curated kits'
141
- : 'Repository sources';
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 ? 'Browse' : 'Kits');
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
- setPending(next);
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 ? 'details' : other?.catalog ? 'switch' : 'setup'}`,
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
- : `${other.catalog ? 'Switch to' : 'Set up'} ${other.label}`;
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) => ['.git', '.loadout', '.agents', '.claude', '.codex'].includes(s)), 'Scope must be a repository directory outside configuration directories');
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 ?? 'Repository');
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
- export declare function ignoredText(original: string, paths: string[]): string;
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 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`;
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/') ? '.loadout' : undefined);
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('Another apply is running (or a previous process stopped). Remove .loadout/apply.lock only after confirming no Loadout process is running.');
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 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
- }
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
@@ -8,4 +8,3 @@ export type Target = {
8
8
  error?: string;
9
9
  };
10
10
  export declare function loadTarget(root: string, global: boolean): Target;
11
- export declare function initializeTarget(target: Target): Target;
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 in .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` → Kits → Continue.
55
+ Drop `--dry-run --diff` to apply, or select via `loadout` → BrowsePersonal → Continue.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lidtop/loadout",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Choose and manage agent instruction and skill kits for repositories and your home directory.",
5
5
  "type": "module",
6
6
  "bin": {