@lidtop/loadout 0.2.0 → 0.3.1

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
@@ -13,7 +13,11 @@ npm install -g @lidtop/loadout
13
13
  loadout
14
14
  ```
15
15
 
16
- Pick your kits, select **Continue**, and confirm. Run `loadout` again to change your selection.
16
+ Pick your kits, select **Review changes**, and confirm. Inside a provider, choose **Back to providers** to keep browsing with your selections preserved. Run `loadout` again to change your selection.
17
+
18
+ The destination selector above the tabs shows **Repository** in cyan (this repository only) and **Global** in magenta (your user configuration across repositories). Active controls and selection markers follow the destination's color. Press **Tab** to switch destinations instantly, or **Shift+Tab** to cycle backward. **←/→** switches between the Kits, Browse, and Installed sections. Each destination keeps its own selections, provider, search, and cursor position, ready to browse as soon as you switch. A **\*** marks destinations with unapplied selections; **Review changes** includes the session's changes across both. Descriptions appear beneath every provider and kit, with dependency or update notes when needed.
19
+
20
+ Press **Esc** at a setup question or review prompt to return to the picker with your selections and position preserved. Press **Esc twice quickly** or **Ctrl+C** to cancel setup.
17
21
 
18
22
  Conflicting project instructions are skipped with a warning; the kit’s skills still install.
19
23
 
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/cli.js CHANGED
@@ -134,7 +134,9 @@ async function generate(catalog, state, opts) {
134
134
  }
135
135
  async function setup() {
136
136
  const { targets, initial } = interactiveTargets();
137
- const configured = await interactive(targets, initial);
137
+ await interactive(targets, initial, { review: reviewSelections });
138
+ }
139
+ async function reviewSelections(configured) {
138
140
  const offline = program.opts().offline;
139
141
  const plans = [];
140
142
  for (const { target, state } of configured) {
@@ -165,7 +167,10 @@ async function setup() {
165
167
  }
166
168
  if (!plans.some(hasChanges))
167
169
  return;
168
- if (await confirmApply()) {
170
+ const scopes = configured
171
+ .filter((_, index) => hasChanges(plans[index]))
172
+ .map(({ target }) => target.label);
173
+ if (await confirmApply(undefined, scopes)) {
169
174
  applyAll(plans);
170
175
  console.log('\nYour loadout is ready.');
171
176
  for (const result of plans) {
@@ -2,9 +2,14 @@ import { checkbox, confirm, select } from '@inquirer/prompts';
2
2
  import { type Catalog, type State } from './schema.js';
3
3
  import { type TargetSelection } from './picker.js';
4
4
  import { type Target } from './targets.js';
5
- export declare function interactive(targets: Target[], initial?: number): Promise<TargetSelection[]>;
5
+ type PromptContext = NonNullable<Parameters<typeof confirm>[1]>;
6
+ export declare function interactive(targets: Target[], initial?: number, options?: {
7
+ context?: PromptContext;
8
+ review?: (selections: TargetSelection[], context?: PromptContext) => Promise<void>;
9
+ }): Promise<TargetSelection[]>;
6
10
  export declare function configureSelection(target: Target, state: State, context?: Parameters<typeof confirm>[1]): Promise<State>;
7
- export declare function confirmApply(): Promise<boolean>;
11
+ export declare function confirmApply(context?: PromptContext, scopes?: string[]): Promise<boolean>;
8
12
  export declare function selectUpdates(catalog: Catalog, state: State, offline?: boolean, context?: Parameters<typeof checkbox>[1]): Promise<string[]>;
9
13
  export declare function confirmRetry(id: string, error: Error, context?: Parameters<typeof select>[1]): Promise<boolean>;
10
14
  export declare function confirmAdoption(paths: string[], context?: Parameters<typeof confirm>[1]): Promise<boolean>;
15
+ export {};
@@ -1,21 +1,57 @@
1
1
  import { checkbox, confirm, select } from '@inquirer/prompts';
2
2
  import { configure } from './resolve.js';
3
3
  import { validAnswer } from './schema.js';
4
- import { targetPicker } from './picker.js';
4
+ import { targetPicker, } from './picker.js';
5
5
  import { availableUpdates, updateDescription } from './updates.js';
6
- export async function interactive(targets, initial = 0) {
7
- if (!process.stdin.isTTY || !process.stdout.isTTY)
6
+ import { prepareInput } from './terminal.js';
7
+ class BackToPicker extends Error {
8
+ }
9
+ async function backPrompt(prompt, context = {}) {
10
+ const input = context.input ?? process.stdin;
11
+ prepareInput(input);
12
+ const controller = new AbortController();
13
+ const back = (_text, key) => {
14
+ if (key.name === 'escape')
15
+ controller.abort(new BackToPicker());
16
+ };
17
+ input.on('keypress', back);
18
+ try {
19
+ return await prompt({
20
+ ...context,
21
+ signal: context.signal
22
+ ? AbortSignal.any([context.signal, controller.signal])
23
+ : controller.signal,
24
+ });
25
+ }
26
+ catch (error) {
27
+ if (error instanceof Error && error.cause instanceof BackToPicker)
28
+ throw error.cause;
29
+ throw error;
30
+ }
31
+ finally {
32
+ input.off('keypress', back);
33
+ }
34
+ }
35
+ export async function interactive(targets, initial = 0, options = {}) {
36
+ if (!options.context && (!process.stdin.isTTY || !process.stdout.isTTY))
8
37
  throw new Error('Interactive setup needs a terminal. Use loadout enable <kit>, then loadout apply.');
9
- const chosen = await targetPicker({
10
- targets,
11
- initial,
12
- });
13
- const configured = [];
14
- for (const { target, state } of chosen) {
15
- const value = await configureSelection(target, state);
16
- configured.push({ target, state: value });
38
+ const session = {};
39
+ for (;;) {
40
+ const chosen = await targetPicker({ targets, initial, session }, options.context);
41
+ try {
42
+ const configured = [];
43
+ for (const { target, state } of chosen) {
44
+ const value = await configureSelection(target, state, options.context);
45
+ configured.push({ target, state: value });
46
+ }
47
+ await options.review?.(configured, options.context);
48
+ return configured;
49
+ }
50
+ catch (error) {
51
+ if (!(error instanceof BackToPicker))
52
+ throw error;
53
+ }
17
54
  }
18
- return configured;
19
55
  }
20
56
  export async function configureSelection(target, state, context) {
21
57
  const change = new Map();
@@ -23,10 +59,10 @@ export async function configureSelection(target, state, context) {
23
59
  if (!change.has(kit)) {
24
60
  const hasSaved = Object.entries(target.catalog.kits.get(kit).questions).some(([name, q]) => validAnswer(q, state.answers[kit]?.[name]));
25
61
  change.set(kit, hasSaved
26
- ? await confirm({
62
+ ? await backPrompt((context) => confirm({
27
63
  message: `${target.label} · ${kit} · Change selection?`,
28
64
  default: false,
29
- }, context)
65
+ }, context), context)
30
66
  : true);
31
67
  }
32
68
  const saved = state.answers[kit]?.[key];
@@ -34,16 +70,21 @@ export async function configureSelection(target, state, context) {
34
70
  return saved;
35
71
  const message = `${target.label} · ${kit} · ${question.message}`;
36
72
  if (question.type === 'boolean')
37
- return confirm({ message, default: typeof value === 'boolean' ? value : false }, context);
38
- return select({
73
+ return backPrompt((context) => confirm({ message, default: typeof value === 'boolean' ? value : false }, context), context);
74
+ return backPrompt((context) => select({
39
75
  message,
40
76
  choices: question.choices.map((v) => ({ name: v, value: v })),
41
77
  default: typeof value === 'string' ? value : undefined,
42
- }, context);
78
+ }, context), context);
43
79
  });
44
80
  }
45
- export async function confirmApply() {
46
- return confirm({ message: 'Apply changes?', default: true });
81
+ export async function confirmApply(context, scopes = []) {
82
+ return backPrompt((context) => confirm({
83
+ message: scopes.length
84
+ ? `Apply changes to ${scopes.join(' and ')}?`
85
+ : 'Apply changes?',
86
+ default: true,
87
+ }, context), context);
47
88
  }
48
89
  export async function selectUpdates(catalog, state, offline = false, context) {
49
90
  const updates = availableUpdates(catalog, state.selected);
@@ -53,7 +94,7 @@ export async function selectUpdates(catalog, state, offline = false, context) {
53
94
  console.log(`${updates.length} catalog update(s) available. Run loadout online to review them; keeping saved versions.`);
54
95
  return [];
55
96
  }
56
- return checkbox({
97
+ return backPrompt((context) => checkbox({
57
98
  message: 'Catalog updates available · choose kits to update (Enter skips)',
58
99
  choices: updates.map((kit) => ({
59
100
  name: kit.id,
@@ -61,20 +102,20 @@ export async function selectUpdates(catalog, state, offline = false, context) {
61
102
  description: updateDescription(kit),
62
103
  })),
63
104
  required: false,
64
- }, context);
105
+ }, context), context);
65
106
  }
66
107
  export async function confirmRetry(id, error, context) {
67
- return select({
108
+ return backPrompt((context) => select({
68
109
  message: `${id}: ${error.message}`,
69
110
  choices: [
70
111
  { name: 'Retry download', value: true },
71
112
  { name: 'Cancel', value: false },
72
113
  ],
73
- }, context);
114
+ }, context), context);
74
115
  }
75
116
  export async function confirmAdoption(paths, context) {
76
- return confirm({
117
+ return backPrompt((context) => confirm({
77
118
  message: `Keep existing content and let Loadout manage ${paths.join(', ')}? Originals will be restored when disabled.`,
78
119
  default: true,
79
- }, context);
120
+ }, context), context);
80
121
  }
package/dist/picker.d.ts CHANGED
@@ -6,11 +6,32 @@ export type PickerConfig = {
6
6
  columns?: number;
7
7
  rows?: number;
8
8
  };
9
+ declare const repositorySections: readonly ['Kits', 'Browse', 'Installed'];
10
+ type Section = (typeof repositorySections)[number];
9
11
  export type TargetPickerConfig = {
10
12
  targets: Target[];
11
13
  initial?: number;
12
14
  columns?: number;
13
15
  rows?: number;
16
+ session?: PickerSession;
17
+ };
18
+ type PickerView = {
19
+ section: Section;
20
+ provider: string | undefined;
21
+ providerList: {
22
+ query: string;
23
+ active: number;
24
+ };
25
+ query: string;
26
+ active: number;
27
+ };
28
+ export type PickerSession = {
29
+ snapshot?: PickerView & {
30
+ targetIndex: number;
31
+ visited: number[];
32
+ selections: string[][];
33
+ views: PickerView[];
34
+ };
14
35
  };
15
36
  export type TargetSelection = {
16
37
  target: Target;
@@ -21,6 +42,7 @@ declare const renderPicker: import("@inquirer/type").Prompt<TargetSelection[], {
21
42
  initial?: number;
22
43
  columns?: number;
23
44
  rows?: number;
45
+ session?: PickerSession;
24
46
  } & TargetPickerConfig>;
25
47
  export declare function targetPicker(config: TargetPickerConfig, context?: Parameters<typeof renderPicker>[1]): Promise<TargetSelection[]>;
26
48
  export declare function kitPicker(config: PickerConfig, context?: Parameters<typeof targetPicker>[1]): Promise<string[]>;
package/dist/picker.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { createPrompt, useEffect, useKeypress, useState, isEnterKey, isSpaceKey, } from '@inquirer/core';
2
2
  import { stripVTControlCharacters, styleText } from 'node:util';
3
3
  import stringWidth from 'string-width';
4
+ import path from 'node:path';
4
5
  import { resolveKits, reasons } from './resolve.js';
5
6
  import { kitSource } from './schema.js';
6
7
  import { availableUpdates, hasUpdate } from './updates.js';
7
8
  import { prepareInput } from './terminal.js';
8
9
  import { providerDescriptions } from './curated.js';
9
- const accent = (value) => styleText('cyan', value);
10
10
  const muted = (value) => styleText('dim', value);
11
11
  const bold = (value) => styleText('bold', value);
12
+ const scopeColor = (target, value) => styleText(target.global ? 'magenta' : 'cyan', value);
12
13
  const clean = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f]/g, ' ');
13
14
  function fit(value, width) {
14
15
  const text = clean(value);
@@ -22,7 +23,7 @@ function fit(value, width) {
22
23
  }
23
24
  return width > 0 ? `${result}…` : '';
24
25
  }
25
- function wordmark(width, rows) {
26
+ function wordmark(width, rows, accent) {
26
27
  if (width < 43 || rows < 20)
27
28
  return ['', ` ${accent(bold('LOADOUT'))}`, ''];
28
29
  return [
@@ -35,28 +36,42 @@ function wordmark(width, rows) {
35
36
  }
36
37
  const repositorySections = ['Kits', 'Browse', 'Installed'];
37
38
  const providerFor = (kit) => kit.origin === 'bundled'
38
- ? 'loadout'
39
+ ? kitSource(kit)
39
40
  : (kit.external?.repo ??
40
41
  (kit.origin === 'personal' ? 'Personal' : undefined));
41
42
  const providerPrefixes = {
42
43
  loadout: 'loadout-',
44
+ 'loadout-agent-clis': 'loadout-',
43
45
  'mattpocock/skills': 'matt-pocock-',
44
46
  'anthropics/skills': 'anthropic-',
45
47
  };
48
+ const initialView = (target) => ({
49
+ section: target.global ||
50
+ ![...(target.catalog?.kits.values() ?? [])].some((kit) => !providerFor(kit))
51
+ ? 'Browse'
52
+ : 'Kits',
53
+ provider: undefined,
54
+ providerList: { query: '', active: 0 },
55
+ query: '',
56
+ active: 0,
57
+ });
46
58
  const emptyState = () => ({
47
59
  schemaVersion: 1,
48
60
  selected: [],
49
61
  answers: {},
50
62
  });
51
63
  const renderPicker = createPrompt((config, done) => {
52
- const [targets, setTargets] = useState(config.targets);
53
- const [targetIndex, setTargetIndex] = useState(config.initial ?? 0);
54
- const [visited, setVisited] = useState([config.initial ?? 0]);
55
- const [selections, setSelections] = useState(config.targets.map((target) => (target.state?.selected ?? []).filter((id) => target.catalog?.kits.has(id) &&
56
- target.catalog.kits.get(id)?.ready !== false)));
57
- const [pendingSwitch, setPendingSwitch] = useState(undefined);
64
+ const snapshot = config.session?.snapshot;
65
+ const targets = config.targets;
66
+ const [views, setViews] = useState(snapshot?.views ?? targets.map(initialView));
67
+ const [targetIndex, setTargetIndex] = useState(snapshot?.targetIndex ?? config.initial ?? 0);
68
+ const [visited, setVisited] = useState(snapshot?.visited ?? [config.initial ?? 0]);
69
+ const [selections, setSelections] = useState(snapshot?.selections ??
70
+ config.targets.map((target) => (target.state?.selected ?? []).filter((id) => target.catalog?.kits.has(id) &&
71
+ target.catalog.kits.get(id)?.ready !== false)));
58
72
  const [notice, setNotice] = useState('');
59
73
  const target = targets[targetIndex];
74
+ const accent = (value) => scopeColor(target, value);
60
75
  const sections = target.global
61
76
  ? ['Browse', 'Installed']
62
77
  : repositorySections;
@@ -65,17 +80,18 @@ const renderPicker = createPrompt((config, done) => {
65
80
  kits: new Map(),
66
81
  };
67
82
  const selected = selections[targetIndex];
68
- const hasSelectionChanges = JSON.stringify([...selected].sort()) !==
69
- JSON.stringify([...(target.state?.selected ?? [])].sort());
83
+ const pendingTargets = targets.filter((item, index) => JSON.stringify([...selections[index]].sort()) !==
84
+ JSON.stringify([...(item.state?.selected ?? [])].sort()));
70
85
  const setSelected = (value) => setSelections(selections.map((ids, index) => (index === targetIndex ? value : ids)));
71
- const [section, setSection] = useState(target.global ||
72
- ![...catalog.kits.values()].some((kit) => !providerFor(kit))
73
- ? 'Browse'
74
- : 'Kits');
75
- const [scopeFocused, setScopeFocused] = useState(false);
76
- const [provider, setProvider] = useState(undefined);
77
- const [query, setQuery] = useState('');
78
- const [active, setActive] = useState(0);
86
+ const [section, setSection] = useState(snapshot?.section ?? views[targetIndex].section);
87
+ const [provider, setProvider] = useState(snapshot?.provider);
88
+ const [providerList, setProviderList] = useState(snapshot?.providerList ?? { query: '', active: 0 });
89
+ const [query, setQuery] = useState(snapshot?.query ?? '');
90
+ const [active, setActive] = useState(snapshot?.active ?? 0);
91
+ useEffect((rl) => {
92
+ if (snapshot?.query)
93
+ rl.write(snapshot.query);
94
+ }, []);
79
95
  const [finished, setFinished] = useState(false);
80
96
  const [size, setSize] = useState({
81
97
  columns: process.stdout.columns || 80,
@@ -134,8 +150,10 @@ const renderPicker = createPrompt((config, done) => {
134
150
  return [];
135
151
  const count = kits.filter((kit) => enabledSet.has(kit.id)).length;
136
152
  const origins = new Set(kits.map((kit) => kit.origin));
137
- const description = id === 'loadout'
138
- ? 'Included with Loadout'
153
+ const description = origins.has('bundled')
154
+ ? id === 'loadout-agent-clis'
155
+ ? 'Delegate tasks through agent CLI harnesses'
156
+ : 'Included with Loadout'
139
157
  : id === 'Personal'
140
158
  ? 'Your personal kits'
141
159
  : origins.has('curated') && providerDescriptions[id]
@@ -173,10 +191,16 @@ const renderPicker = createPrompt((config, done) => {
173
191
  .sort((a, b) => Number(a.ready === false) - Number(b.ready === false))
174
192
  .map((kit) => ({ id: kit.id, description: kit.description, kit }));
175
193
  }
194
+ if (provider)
195
+ entries.push({
196
+ id: '@back',
197
+ action: 'back',
198
+ description: 'Keeps your selections',
199
+ });
176
200
  entries.push({
177
- id: '@continue',
178
- action: 'continue',
179
- description: 'Review selections and apply',
201
+ id: '@review',
202
+ action: 'review',
203
+ description: '',
180
204
  });
181
205
  const cursor = Math.min(active, Math.max(0, entries.length - 1));
182
206
  const focused = entries[cursor];
@@ -190,72 +214,77 @@ const renderPicker = createPrompt((config, done) => {
190
214
  rl.clearLine(0);
191
215
  rl.write(query);
192
216
  };
193
- const switchTarget = (index) => {
194
- setScopeFocused(false);
195
- setTargetIndex(index);
196
- setSection(targets[index].global ||
197
- ![...targets[index].catalog.kits.values()].some((kit) => !providerFor(kit))
198
- ? 'Browse'
199
- : 'Kits');
200
- setVisited([...new Set([...visited, index])]);
217
+ const backToProviders = () => {
201
218
  setProvider(undefined);
202
- setNotice('');
203
- reset();
219
+ setQuery(providerList.query);
220
+ setActive(providerList.active);
221
+ rl.clearLine(0);
222
+ rl.write(providerList.query);
204
223
  };
205
- if (pendingSwitch !== undefined) {
206
- if (key.name === 'y') {
207
- switchTarget(pendingSwitch);
208
- setPendingSwitch(undefined);
209
- }
210
- else if (key.name === 'n' ||
211
- key.name === 'escape' ||
212
- isEnterKey(key)) {
213
- setPendingSwitch(undefined);
224
+ const switchTarget = (index) => {
225
+ if (index === targetIndex) {
214
226
  restoreInput();
227
+ return;
215
228
  }
216
- return;
217
- }
218
- if (scopeFocused && (isEnterKey(key) || isSpaceKey(key))) {
219
- setScopeFocused(false);
220
- const next = (targetIndex + 1) % targets.length;
221
- const other = targets[next];
222
- if (other.error)
223
- setNotice(other.error);
224
- else if (!other.catalog)
225
- setNotice('Cannot load this location.');
226
- else if (hasSelectionChanges) {
227
- setPendingSwitch(next);
229
+ const next = targets[index];
230
+ if (next.error || !next.catalog) {
231
+ setNotice(next.error ?? 'Cannot load this location.');
232
+ restoreInput();
233
+ return;
228
234
  }
229
- else
230
- switchTarget(next);
235
+ setViews(views.map((view, index) => index === targetIndex
236
+ ? { section, provider, providerList, query, active: cursor }
237
+ : view));
238
+ const view = views[index];
239
+ setTargetIndex(index);
240
+ setSection(view.section);
241
+ setProvider(view.provider);
242
+ setProviderList(view.providerList);
243
+ setQuery(view.query);
244
+ setActive(view.active);
245
+ setVisited([...new Set([...visited, index])]);
246
+ setNotice('');
231
247
  rl.clearLine(0);
248
+ rl.write(view.query);
249
+ };
250
+ if (key.name === 'tab') {
251
+ switchTarget((targetIndex + (key.shift ? -1 : 1) + targets.length) %
252
+ targets.length);
232
253
  return;
233
254
  }
234
- if (['tab', 'left', 'right'].includes(key.name)) {
235
- const index = scopeFocused
236
- ? sections.length
237
- : sections.indexOf(section);
238
- const count = sections.length + (targets.length > 1 ? 1 : 0);
239
- const backwards = key.name === 'left' || (key.name === 'tab' && key.shift);
255
+ if (['left', 'right'].includes(key.name)) {
256
+ const index = sections.indexOf(section);
257
+ const count = sections.length;
258
+ const backwards = key.name === 'left';
240
259
  const next = (index + (backwards ? -1 : 1) + count) % count;
241
- setScopeFocused(next === sections.length);
242
- if (next !== sections.length) {
243
- setSection(sections[next]);
244
- setProvider(undefined);
245
- reset();
246
- }
247
- else
248
- restoreInput();
260
+ setSection(sections[next]);
261
+ setProvider(undefined);
262
+ reset();
249
263
  }
250
264
  else if (key.name === 'up' || key.name === 'down') {
251
- setScopeFocused(false);
252
265
  if (entries.length)
253
266
  setActive((cursor + (key.name === 'up' ? -1 : 1) + entries.length) %
254
267
  entries.length);
255
268
  restoreInput();
256
269
  }
257
270
  else if (isEnterKey(key) || isSpaceKey(key)) {
258
- if (focused?.action === 'continue') {
271
+ if (focused?.action === 'back') {
272
+ backToProviders();
273
+ return;
274
+ }
275
+ else if (focused?.action === 'review') {
276
+ if (config.session)
277
+ config.session.snapshot = {
278
+ targetIndex,
279
+ visited,
280
+ selections,
281
+ views,
282
+ section,
283
+ provider,
284
+ providerList,
285
+ query,
286
+ active: cursor,
287
+ };
259
288
  setFinished(true);
260
289
  done(targets.flatMap((item, index) => {
261
290
  if (!item.catalog || !visited.includes(index))
@@ -270,6 +299,7 @@ const renderPicker = createPrompt((config, done) => {
270
299
  }));
271
300
  }
272
301
  else if (browsingProviders && focused) {
302
+ setProviderList({ query, active: cursor });
273
303
  setProvider(focused.id);
274
304
  setActive(0);
275
305
  }
@@ -283,124 +313,115 @@ const renderPicker = createPrompt((config, done) => {
283
313
  rl.write(query);
284
314
  }
285
315
  else if (key.name === 'escape') {
286
- if (scopeFocused) {
287
- setScopeFocused(false);
288
- restoreInput();
289
- }
290
- else {
291
- if (!query && provider)
292
- setProvider(undefined);
316
+ if (!query && provider)
317
+ backToProviders();
318
+ else
293
319
  reset();
294
- }
295
320
  }
296
321
  else if (!['left', 'right', 'home', 'end'].includes(key.name)) {
297
- setScopeFocused(false);
298
322
  setQuery(clean(rl.line));
299
323
  setActive(0);
300
324
  }
301
325
  });
302
- const header = wordmark(width, height);
303
- const other = targets.length > 1
304
- ? targets[(targetIndex + 1) % targets.length]
305
- : undefined;
306
- const switchLabel = other
307
- ? `[ Go to ${other.label}${other.error ? ' !' : ''} ]`
308
- : '';
309
- const scopeLabel = width >= 60 ? `${target.label} · ${target.root}` : target.label;
310
- const scopeText = fit(scopeLabel, width - switchLabel.length - 4);
311
- const scopeLines = targets.length > 1 || target.global
312
- ? [
313
- ` ${bold(scopeText)}${' '.repeat(Math.max(2, width - stringWidth(scopeText) - switchLabel.length - 2))}${scopeFocused ? accent(bold(switchLabel)) : muted(switchLabel)}`,
314
- ...(width < 60 ? [` ${muted(fit(target.root, width - 2))}`] : []),
315
- ]
316
- : [];
317
- const selectionWarning = hasSelectionChanges
318
- ? [
319
- ` ${fit(`Unapplied changes in ${target.label}.`, width - 2)}`,
320
- ' Selections stay in this session.',
321
- ]
322
- : [];
323
- if (pendingSwitch !== undefined)
324
- return [
325
- ...header,
326
- ...scopeLines,
327
- '',
328
- ` ${bold(fit(`Switch to ${targets[pendingSwitch].label}?`, width - 2))}`,
329
- ...selectionWarning,
330
- '',
331
- ` ${accent('[Enter/Esc]')} Stay ${accent('[y]')} Switch`,
332
- '\u001b[?25l',
333
- ].join('\n');
326
+ const scopeLabels = targets.map((item, index) => `[${index === targetIndex ? '●' : '○'} ${clean(item.label)}${pendingTargets.includes(item) ? '*' : ''}${item.error || !item.catalog ? ' !' : ''}]`);
327
+ const baseWidth = scopeLabels.reduce((sum, label) => sum + stringWidth(label), 0) +
328
+ (targets.length - 1) * 2;
329
+ const repositoryIndex = targets.findIndex((item) => !item.global);
330
+ const nameWidth = width - 4 - baseWidth - 3;
331
+ if (repositoryIndex >= 0 && nameWidth >= 3) {
332
+ const repository = targets[repositoryIndex];
333
+ const name = fit(path.basename(repository.root) || repository.root, nameWidth);
334
+ scopeLabels[repositoryIndex] = scopeLabels[repositoryIndex].replace(clean(repository.label), () => `${clean(repository.label)} · ${name}`);
335
+ }
336
+ const segments = targets.map((item, index) => {
337
+ const label = scopeLabels[index];
338
+ const colored = scopeColor(item, label);
339
+ return index === targetIndex
340
+ ? bold(colored)
341
+ : pendingTargets.includes(item)
342
+ ? colored
343
+ : muted(colored);
344
+ });
345
+ const selectorWidth = segments.reduce((sum, item) => sum + stringWidth(item), 0) +
346
+ (segments.length - 1) * 2;
347
+ const scopeLines = selectorWidth <= width - 4
348
+ ? [` ${segments.join(' ')}`]
349
+ : segments.map((item) => ` ${item}`);
334
350
  if (finished)
335
351
  return [
336
- ...header,
352
+ ...wordmark(width, height, accent),
337
353
  ` ${accent('✓')} ${selected.length} selected${requiredCount ? muted(` · ${requiredCount} required`) : ''}\n`,
338
354
  ].join('\n');
339
355
  const updates = availableUpdates(catalog).length;
340
356
  const counts = `${selected.length} selected${requiredCount ? ` · ${requiredCount} required` : ''}`;
357
+ const tabLabel = (name) => name === 'Browse' && provider ? `Browse › ${clean(provider)}` : name;
358
+ const inlineProvider = !!provider && sections.map(tabLabel).join(' ').length + 2 <= width - 2;
341
359
  const tabs = width >= 34
342
- ? ` ${sections.map((name) => (name === section && !scopeFocused ? accent(bold(`[${name}]`)) : muted(name))).join(' ')}`
343
- : ` ${scopeFocused ? muted(section) : accent(bold(`[${section}]`))} ${muted('←→/tab')}`;
360
+ ? ` ${sections
361
+ .map((name) => {
362
+ const label = inlineProvider ? tabLabel(name) : name;
363
+ return name === section
364
+ ? accent(bold(`[${label}]`))
365
+ : muted(label);
366
+ })
367
+ .join(' ')}`
368
+ : ` ${accent(bold(`[${section}]`))}`;
344
369
  const filter = ` ${accent('/')} ${query ? `${fit(query, width - 6)}${accent('▏')}` : muted(browsingProviders ? 'Search providers or kits' : 'Search kits')}`;
345
- const rule = ` ${muted(''.repeat(width - 2))}`;
346
- const detailed = height >= 20;
347
- const hints = scopeFocused
348
- ? [
349
- `space/enter ${other?.error || !other?.catalog ? 'details' : 'switch'}`,
350
- '←→/tab move',
351
- 'esc back',
352
- ]
353
- : [
354
- '↑↓ move',
355
- `space/enter ${focused?.action ? 'select' : browsingProviders ? 'open' : 'toggle'}`,
356
- '←→/tab switch',
357
- `esc ${provider ? 'back' : 'clear'}`,
358
- ];
359
- const helpLines = [];
360
- const separator = width >= 100 ? ' ' : ' · ';
370
+ const escapeHint = query ? 'Esc clear' : provider ? 'Esc back' : '';
371
+ const hints = [
372
+ ...(width >= 60 ? ['↑↓ move'] : []),
373
+ focused?.action
374
+ ? 'Enter select'
375
+ : browsingProviders
376
+ ? 'Space open'
377
+ : 'Space toggle',
378
+ targets.length > 1 ? 'Tab switch scope' : '←→ tabs',
379
+ escapeHint,
380
+ ].filter(Boolean);
381
+ let helpText = '';
361
382
  for (const hint of hints) {
362
- const previous = helpLines.at(-1);
363
- const combined = previous ? `${previous}${separator}${hint}` : hint;
364
- if (previous && stringWidth(combined) <= width - 2)
365
- helpLines[helpLines.length - 1] = combined;
366
- else
367
- helpLines.push(fit(hint, width - 2));
368
- }
369
- const help = helpLines.map((line) => ` ${muted(line)}`);
370
- if (scopeFocused && other) {
371
- const action = other.error || !other.catalog
372
- ? `${other.label} is unavailable`
373
- : `Switch to ${other.label}`;
374
- return [
375
- ...header,
376
- ...scopeLines,
377
- '',
378
- tabs,
379
- rule,
380
- ` ${bold(fit(action, width - 2))}`,
381
- ...(other.error ? [` ${fit(other.error, width - 2)}`] : []),
382
- ` ${muted(fit(other.root, width - 2))}`,
383
- rule,
384
- ...help,
385
- '\u001b[?25l',
386
- ].join('\n');
383
+ const combined = helpText ? `${helpText} · ${hint}` : hint;
384
+ if (stringWidth(combined) <= width - 2)
385
+ helpText = combined;
387
386
  }
387
+ const help = [` ${muted(helpText)}`];
388
388
  const beforeList = [
389
- ...header,
390
389
  ...scopeLines,
391
- ...(scopeLines.length && detailed ? [''] : []),
392
390
  tabs,
393
- ...(provider ? [` ${muted(fit(provider, width - 2))}`] : []),
394
- ...(detailed ? [''] : []),
395
- rule,
391
+ ...(provider && !inlineProvider
392
+ ? [` ${muted(fit(provider, width - 2))}`]
393
+ : []),
396
394
  filter,
397
- ...(detailed ? [''] : []),
398
395
  ];
399
396
  const summary = fit(`${counts}${updates ? ` · ${updates} updates` : ''}`, width - 6);
400
- const bottomRule = ` ${muted(`${'─'.repeat(Math.max(1, width - stringWidth(summary) - 6))} ${summary} ─`)}`;
401
- // Border, detail, spacing, keyboard help, and cursor-control line.
402
- const footerHeight = 5 + help.length;
403
- const pageSize = Math.max(1, Math.min(8, Math.floor((height - beforeList.length - footerHeight) / (detailed ? 2 : 1))));
397
+ const actions = entries.filter((row) => row.action);
398
+ const kit = focused?.kit;
399
+ const why = kit ? reasons(catalog, selected, kit.id) : [];
400
+ const detail = notice ||
401
+ (kit?.ready === false
402
+ ? 'Edit this kit, then set ready: true in kit.yaml'
403
+ : kit && willUninstall(kit.id)
404
+ ? 'Uninstall on apply. Select again to keep.'
405
+ : why.length
406
+ ? `Required by ${why.map(displayName).join(', ')}${selected.includes(kit.id) ? ' · also selected' : ' · space to keep explicitly'}`
407
+ : kit?.requires.length
408
+ ? `Requires ${kit.requires.map(displayName).join(', ')}`
409
+ : kit && hasUpdate(kit)
410
+ ? `Catalog update · ${kit.pinned.ref.slice(0, 8)} → ${kit.external.ref.slice(0, 8)}`
411
+ : '');
412
+ // Keep the contextual row allocated so focus changes never move the footer.
413
+ const footerHeight = 3 + actions.length + help.length;
414
+ // Prefer useful list rows over decorative branding on short terminals.
415
+ const header = wordmark(width, height, accent);
416
+ const headerBudget = height - beforeList.length - footerHeight - (height >= 20 ? 8 : 4);
417
+ if (header.length <= headerBudget)
418
+ beforeList.unshift(...header);
419
+ else if (headerBudget >= 1)
420
+ beforeList.unshift(` ${accent(bold('LOADOUT'))}`);
421
+ const spacing = height - beforeList.length - footerHeight >= 8 ? 1 : 0;
422
+ if (spacing)
423
+ beforeList.push('');
424
+ const pageSize = Math.max(1, Math.min(8, Math.floor((height - beforeList.length - footerHeight) / 2)));
404
425
  const listEntries = entries.filter((row) => !row.action);
405
426
  const listCursor = Math.min(cursor, Math.max(0, listEntries.length - 1));
406
427
  const start = Math.max(0, Math.min(listCursor - Math.floor(pageSize / 2), listEntries.length - pageSize));
@@ -433,28 +454,24 @@ const renderPicker = createPrompt((config, done) => {
433
454
  ? 'Will uninstall'
434
455
  : hasUpdate(row.kit)
435
456
  ? 'update'
436
- : explicit
437
- ? 'selected'
438
- : required
439
- ? 'required'
440
- : row.kit.pinned
441
- ? 'saved'
442
- : '';
457
+ : required
458
+ ? 'required'
459
+ : row.kit.pinned && !explicit
460
+ ? 'saved'
461
+ : '';
443
462
  const marker = !row.kit
444
463
  ? accent('▸')
445
464
  : explicit
446
465
  ? accent('●')
447
466
  : required
448
467
  ? styleText('yellow', '◆')
449
- : muted('○');
468
+ : muted(accent('○'));
450
469
  const badge = required || willUninstall(row.id) || (row.kit && hasUpdate(row.kit))
451
470
  ? styleText('yellow', label)
452
471
  : muted(label);
453
472
  const name = fit(displayName(row.id), width - label.length - 9);
454
473
  const gap = ' '.repeat(Math.max(2, width - stringWidth(name) - label.length - 6));
455
- lines.push(` ${focus ? accent('›') : ' '} ${marker} ${row.kit?.ready === false ? muted(name) : focus ? bold(name) : name}${gap}${badge}`);
456
- if (detailed)
457
- lines.push(` ${muted(fit(row.kit && section === 'Installed' ? `${kitSource(row.kit)} · ${row.description}` : row.description, width - 6))}`);
474
+ lines.push(` ${focus ? accent('›') : ' '} ${marker} ${row.kit?.ready === false ? muted(name) : focus ? bold(name) : name}${gap}${badge}`, ` ${muted(fit(row.kit && section === 'Installed' ? `${kitSource(row.kit)} · ${row.description}` : row.description, width - 6))}`);
458
475
  }
459
476
  if (!page.length) {
460
477
  const empty = !catalog.kits.size
@@ -462,48 +479,38 @@ const renderPicker = createPrompt((config, done) => {
462
479
  : query
463
480
  ? 'No matching kits. Esc to clear.'
464
481
  : section === 'Kits'
465
- ? 'No repository kits. Tab to browse.'
482
+ ? 'No repository kits. ←→ to browse.'
466
483
  : section === 'Installed'
467
- ? 'No kits installed. Tab to browse.'
484
+ ? 'No kits installed. ←→ to browse.'
468
485
  : 'No external providers configured.';
469
486
  lines.push(` ${muted(fit(empty, width - 2))}`);
470
487
  }
471
- const kit = focused?.kit;
472
- const why = kit ? reasons(catalog, selected, kit.id) : [];
473
- const detail = focused?.action
474
- ? focused.description
475
- : browsingProviders
476
- ? [
477
- focused?.selectedCount ? `${focused.selectedCount} selected` : '',
478
- focused?.downloadedCount
479
- ? `${focused.downloadedCount} downloaded`
480
- : '',
481
- ]
482
- .filter(Boolean)
483
- .join(' · ') || 'Choose a provider to explore its kits'
484
- : kit?.ready === false
485
- ? 'Edit this kit, then set ready: true in kit.yaml'
486
- : kit && willUninstall(kit.id)
487
- ? 'Uninstall on apply. Select again to keep.'
488
- : kit && hasUpdate(kit)
489
- ? `Catalog update · ${kit.pinned.ref.slice(0, 8)} → ${kit.external.ref.slice(0, 8)}`
490
- : why.length
491
- ? `Required by ${why.map(displayName).join(', ')}${selected.includes(kit.id) ? ' · also selected' : ' · space to keep explicitly'}`
492
- : kit?.requires.length
493
- ? `Requires ${kit.requires.map(displayName).join(', ')}`
494
- : kit?.external
495
- ? `Includes ${(kit.pinned ?? kit.external).skills.map((p) => p.split('/').at(-1)).join(', ')}`
496
- : '';
497
488
  const pagination = listEntries.length > page.length
498
489
  ? `${start + 1}–${start + page.length} of ${listEntries.length}`
499
490
  : '';
491
+ const status = fit([summary, pagination].filter(Boolean).join(' · '), width - 6);
492
+ const bottomRule = ` ${muted('─'.repeat(Math.max(1, width - stringWidth(status) - 6)))} ${scopeColor(target, status)} ${muted('─')}`;
500
493
  return [
501
494
  ...beforeList,
502
495
  ...lines,
496
+ ` ${muted(fit(detail, width - 2))}`,
503
497
  bottomRule,
504
- ` ${focused?.action ? accent('›') : ' '} ${focused?.action ? accent(bold('[ Continue ]')) : '[ Continue ]'}`,
505
- ` ${muted(fit(notice || [pagination, detail].filter(Boolean).join(' · '), width - 2))}`,
506
- '',
498
+ ...actions.map((row) => {
499
+ const label = row.action === 'back'
500
+ ? width >= 25
501
+ ? 'Back to providers'
502
+ : 'Back'
503
+ : width >= 22
504
+ ? 'Review changes'
505
+ : 'Review';
506
+ const button = fit(`[ ${label} ]`, width - 4);
507
+ const focus = row.id === focused?.id;
508
+ const helperWidth = width - stringWidth(button) - 6;
509
+ const helper = row.description && helperWidth > 0
510
+ ? ` ${muted(fit(row.description, helperWidth))}`
511
+ : '';
512
+ return ` ${focus ? accent('›') : ' '} ${focus ? accent(bold(button)) : button}${helper}`;
513
+ }),
507
514
  ...help,
508
515
  '\u001b[?25l',
509
516
  ].join('\n');
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');
@@ -134,7 +135,7 @@ export function parse(schema, value, label) {
134
135
  }
135
136
  export function kitSource(kit) {
136
137
  return kit.origin === 'bundled'
137
- ? 'loadout'
138
+ ? bundledProvider(kit.id)
138
139
  : ((kit.pinned ?? kit.external)?.repo ??
139
140
  (kit.origin === 'personal' ? 'Personal' : 'Repository'));
140
141
  }
@@ -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 @@
1
+ Do not commit changes or perform Git operations. Leave Git to the user.
@@ -0,0 +1,6 @@
1
+ schemaVersion: 1
2
+ id: dont-commit
3
+ description: Leave commits and Git operations to the user
4
+ outputs:
5
+ - type: instructions
6
+ source: instructions.md
@@ -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.
@@ -5,6 +5,8 @@ description: Create or edit private or shared Loadout kits.
5
5
 
6
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
+ State each requirement once as a direct instruction, including its exceptions. Preserve its exact trigger and scope. Omit introductions, summaries, explanations of familiar concepts, and lists of actions a rule already covers. Do not add unrequested rules or steps.
9
+
8
10
  `.loadout-personal/kits/review/kit.yaml`:
9
11
 
10
12
  ```yaml
@@ -52,4 +54,4 @@ loadout enable review --answer review.diagrams=true --answer review.detail=detai
52
54
  loadout disable review
53
55
  ```
54
56
 
55
- Drop `--dry-run --diff` to apply, or select via `loadout` → Browse → Personal → Continue.
57
+ Drop `--dry-run --diff` to apply, or select via `loadout` → Browse → Personal → Review changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lidtop/loadout",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Choose and manage agent instruction and skill kits for repositories and your home directory.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +0,0 @@
1
- export declare function stateDirectory(root: string): string;
package/dist/locations.js DELETED
@@ -1,4 +0,0 @@
1
- import { safePath } from './fs.js';
2
- export function stateDirectory(root) {
3
- return safePath(root, '.loadout-personal');
4
- }