@lidtop/loadout 0.1.0 → 0.3.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/bundled.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export declare const bundledRoot: string;
2
+ export declare function bundledProvider(id: string): string;
package/dist/bundled.js CHANGED
@@ -1,3 +1,11 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  // Shared by source runs and the published dist/ entry point.
3
3
  export const bundledRoot = fileURLToPath(new URL('../kits/', import.meta.url));
4
+ const agentCliKits = new Set([
5
+ 'loadout-claude-cli',
6
+ 'loadout-codex-cli',
7
+ 'loadout-opencode-cli',
8
+ ]);
9
+ export function bundledProvider(id) {
10
+ return agentCliKits.has(id) ? 'loadout-agent-clis' : 'loadout';
11
+ }
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) {
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,9 +34,13 @@ 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
+ ? kitSource(kit)
39
+ : (kit.external?.repo ??
40
+ (kit.origin === 'personal' ? 'Personal' : undefined));
39
41
  const providerPrefixes = {
40
42
  loadout: 'loadout-',
43
+ 'loadout-agent-clis': 'loadout-',
41
44
  'mattpocock/skills': 'matt-pocock-',
42
45
  'anthropics/skills': 'anthropic-',
43
46
  };
@@ -52,7 +55,6 @@ const renderPicker = createPrompt((config, done) => {
52
55
  const [visited, setVisited] = useState([config.initial ?? 0]);
53
56
  const [selections, setSelections] = useState(config.targets.map((target) => (target.state?.selected ?? []).filter((id) => target.catalog?.kits.has(id) &&
54
57
  target.catalog.kits.get(id)?.ready !== false)));
55
- const [pending, setPending] = useState(undefined);
56
58
  const [pendingSwitch, setPendingSwitch] = useState(undefined);
57
59
  const [notice, setNotice] = useState('');
58
60
  const target = targets[targetIndex];
@@ -67,7 +69,10 @@ const renderPicker = createPrompt((config, done) => {
67
69
  const hasSelectionChanges = JSON.stringify([...selected].sort()) !==
68
70
  JSON.stringify([...(target.state?.selected ?? [])].sort());
69
71
  const setSelected = (value) => setSelections(selections.map((ids, index) => (index === targetIndex ? value : ids)));
70
- const [section, setSection] = useState(target.global ? 'Browse' : 'Kits');
72
+ const [section, setSection] = useState(target.global ||
73
+ ![...catalog.kits.values()].some((kit) => !providerFor(kit))
74
+ ? 'Browse'
75
+ : 'Kits');
71
76
  const [scopeFocused, setScopeFocused] = useState(false);
72
77
  const [provider, setProvider] = useState(undefined);
73
78
  const [query, setQuery] = useState('');
@@ -130,15 +135,19 @@ const renderPicker = createPrompt((config, done) => {
130
135
  return [];
131
136
  const count = kits.filter((kit) => enabledSet.has(kit.id)).length;
132
137
  const origins = new Set(kits.map((kit) => kit.origin));
133
- const description = id === 'loadout'
134
- ? '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';
138
+ const description = origins.has('bundled')
139
+ ? id === 'loadout-agent-clis'
140
+ ? 'Delegate tasks through agent CLI harnesses'
141
+ : 'Included with Loadout'
142
+ : id === 'Personal'
143
+ ? 'Your personal kits'
144
+ : origins.has('curated') && providerDescriptions[id]
145
+ ? providerDescriptions[id]
146
+ : origins.size > 1
147
+ ? 'Curated and repository sources'
148
+ : origins.has('curated')
149
+ ? 'Curated kits'
150
+ : 'Repository sources';
142
151
  return [
143
152
  {
144
153
  id,
@@ -187,7 +196,10 @@ const renderPicker = createPrompt((config, done) => {
187
196
  const switchTarget = (index) => {
188
197
  setScopeFocused(false);
189
198
  setTargetIndex(index);
190
- setSection(targets[index].global ? 'Browse' : 'Kits');
199
+ setSection(targets[index].global ||
200
+ ![...targets[index].catalog.kits.values()].some((kit) => !providerFor(kit))
201
+ ? 'Browse'
202
+ : 'Kits');
191
203
  setVisited([...new Set([...visited, index])]);
192
204
  setProvider(undefined);
193
205
  setNotice('');
@@ -206,40 +218,14 @@ const renderPicker = createPrompt((config, done) => {
206
218
  }
207
219
  return;
208
220
  }
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
221
  if (scopeFocused && (isEnterKey(key) || isSpaceKey(key))) {
234
222
  setScopeFocused(false);
235
223
  const next = (targetIndex + 1) % targets.length;
236
224
  const other = targets[next];
237
225
  if (other.error)
238
226
  setNotice(other.error);
239
- else if (!other.catalog) {
240
- setPending(next);
241
- reset();
242
- }
227
+ else if (!other.catalog)
228
+ setNotice('Cannot load this location.');
243
229
  else if (hasSelectionChanges) {
244
230
  setPendingSwitch(next);
245
231
  }
@@ -348,27 +334,6 @@ const renderPicker = createPrompt((config, done) => {
348
334
  ` ${accent('[Enter/Esc]')} Stay ${accent('[y]')} Switch`,
349
335
  '\u001b[?25l',
350
336
  ].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
337
  if (finished)
373
338
  return [
374
339
  ...header,
@@ -384,7 +349,7 @@ const renderPicker = createPrompt((config, done) => {
384
349
  const detailed = height >= 20;
385
350
  const hints = scopeFocused
386
351
  ? [
387
- `space/enter ${other?.error ? 'details' : other?.catalog ? 'switch' : 'setup'}`,
352
+ `space/enter ${other?.error || !other?.catalog ? 'details' : 'switch'}`,
388
353
  '←→/tab move',
389
354
  'esc back',
390
355
  ]
@@ -406,9 +371,9 @@ const renderPicker = createPrompt((config, done) => {
406
371
  }
407
372
  const help = helpLines.map((line) => ` ${muted(line)}`);
408
373
  if (scopeFocused && other) {
409
- const action = other.error
374
+ const action = other.error || !other.catalog
410
375
  ? `${other.label} is unavailable`
411
- : `${other.catalog ? 'Switch to' : 'Set up'} ${other.label}`;
376
+ : `Switch to ${other.label}`;
412
377
  return [
413
378
  ...header,
414
379
  ...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
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { bundledProvider } from './bundled.js';
2
3
  export const idSchema = z
3
4
  .string()
4
5
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase words separated by hyphens');
@@ -14,7 +15,14 @@ export const relativePath = z
14
15
  .every((s) => s !== '..' && s !== '.' && s !== '' && !s.endsWith(' '))), 'Use a relative path without traversal, glob characters, or backslashes');
15
16
  export const scopeSchema = relativePath.refine((p) => !p
16
17
  .split('/')
17
- .some((s) => ['.git', '.loadout', '.agents', '.claude', '.codex'].includes(s)), 'Scope must be a repository directory outside configuration directories');
18
+ .some((s) => [
19
+ '.git',
20
+ '.loadout',
21
+ '.loadout-personal',
22
+ '.agents',
23
+ '.claude',
24
+ '.codex',
25
+ ].includes(s)), 'Scope must be a repository directory outside configuration directories');
18
26
  const condition = z
19
27
  .object({ answer: idSchema, equals: z.union([z.boolean(), z.string()]) })
20
28
  .strict();
@@ -127,8 +135,9 @@ export function parse(schema, value, label) {
127
135
  }
128
136
  export function kitSource(kit) {
129
137
  return kit.origin === 'bundled'
130
- ? 'loadout'
131
- : ((kit.pinned ?? kit.external)?.repo ?? 'Repository');
138
+ ? bundledProvider(kit.id)
139
+ : ((kit.pinned ?? kit.external)?.repo ??
140
+ (kit.origin === 'personal' ? 'Personal' : 'Repository'));
132
141
  }
133
142
  export function sameSource(a, b) {
134
143
  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
- }
@@ -0,0 +1,2 @@
1
+ Use `--model opus` by default for Claude CLI; use `--model fable` only when asked. Start with `claude -p --model opus --output-format json "PROMPT"` and capture `session_id` from the response.
2
+ For follow-ups, run `claude -p --resume SESSION_ID --model opus --output-format json "FOLLOW-UP"` in the same working directory; substitute `fable` in both commands when requested.
@@ -0,0 +1,19 @@
1
+ schemaVersion: 1
2
+ id: claude-cli
3
+ description: Run non-interactive Claude CLI conversations
4
+ questions:
5
+ placement:
6
+ type: choice
7
+ message: Add Claude CLI guidance to context or as a skill?
8
+ choices: [context, skill]
9
+ outputs:
10
+ - type: instructions
11
+ source: instructions.md
12
+ when:
13
+ answer: placement
14
+ equals: context
15
+ - type: skill
16
+ source: skills/loadout-claude-cli
17
+ when:
18
+ answer: placement
19
+ equals: skill
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: loadout-claude-cli
3
+ description: Run non-interactive Claude CLI prompts and follow-up conversations. Use when asked to delegate a task to Claude CLI or continue a Claude CLI session.
4
+ ---
5
+
6
+ Use `--model opus` by default for Claude CLI; use `--model fable` only when asked. Start with `claude -p --model opus --output-format json "PROMPT"` and capture `session_id` from the response.
7
+ For follow-ups, run `claude -p --resume SESSION_ID --model opus --output-format json "FOLLOW-UP"` in the same working directory; substitute `fable` in both commands when requested.
@@ -0,0 +1,2 @@
1
+ Use `--model gpt-5.6-sol` by default; use `--model gpt-6-astra` only when asked. Start with `codex exec --model gpt-5.6-sol --json "PROMPT"` and capture `thread_id` from the output.
2
+ For follow-ups, run `codex exec resume --model gpt-5.6-sol --json SESSION_ID "FOLLOW-UP"` in the same working directory; substitute `gpt-6-astra` in both commands when requested.
@@ -0,0 +1,19 @@
1
+ schemaVersion: 1
2
+ id: codex-cli
3
+ description: Run non-interactive Codex CLI conversations
4
+ questions:
5
+ placement:
6
+ type: choice
7
+ message: Add Codex CLI guidance to context or as a skill?
8
+ choices: [context, skill]
9
+ outputs:
10
+ - type: instructions
11
+ source: instructions.md
12
+ when:
13
+ answer: placement
14
+ equals: context
15
+ - type: skill
16
+ source: skills/loadout-codex-cli
17
+ when:
18
+ answer: placement
19
+ equals: skill
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: loadout-codex-cli
3
+ description: Run non-interactive Codex CLI prompts and follow-up conversations. Use when asked to delegate a task to Codex CLI or continue a Codex CLI session.
4
+ ---
5
+
6
+ Use `--model gpt-5.6-sol` by default; use `--model gpt-6-astra` only when asked. Start with `codex exec --model gpt-5.6-sol --json "PROMPT"` and capture `thread_id` from the output.
7
+ For follow-ups, run `codex exec resume --model gpt-5.6-sol --json SESSION_ID "FOLLOW-UP"` in the same working directory; substitute `gpt-6-astra` in both commands when requested.
@@ -0,0 +1,2 @@
1
+ Start with `opencode run --format json "PROMPT"` using the configured default model, and capture `sessionID` from the output.
2
+ For follow-ups, run `opencode run --session SESSION_ID --format json "FOLLOW-UP"` in the same working directory.
@@ -0,0 +1,19 @@
1
+ schemaVersion: 1
2
+ id: opencode-cli
3
+ description: Run non-interactive OpenCode CLI conversations
4
+ questions:
5
+ placement:
6
+ type: choice
7
+ message: Add OpenCode CLI guidance to context or as a skill?
8
+ choices: [context, skill]
9
+ outputs:
10
+ - type: instructions
11
+ source: instructions.md
12
+ when:
13
+ answer: placement
14
+ equals: context
15
+ - type: skill
16
+ source: skills/loadout-opencode-cli
17
+ when:
18
+ answer: placement
19
+ equals: skill
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: loadout-opencode-cli
3
+ description: Run non-interactive OpenCode CLI prompts and follow-up conversations. Use when asked to delegate a task to OpenCode CLI or continue an OpenCode CLI session.
4
+ ---
5
+
6
+ Start with `opencode run --format json "PROMPT"` using the configured default model, and capture `sessionID` from the output.
7
+ For follow-ups, run `opencode run --session SESSION_ID --format json "FOLLOW-UP"` in the same working directory.
@@ -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.3.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": {