@lidtop/loadout 0.3.1 → 0.3.2

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,11 +13,11 @@ npm install -g @lidtop/loadout
13
13
  loadout
14
14
  ```
15
15
 
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.
16
+ Pick your kits and select **Review changes**. Any kit questions and downloads happen automatically, then one compact review shows your kit changes and file totals for Repository and Global. Expand **Unchanged** to inspect your complete selection, or choose **View files** to browse paths and diffs. Choose **Apply changes** to save, or **Back to kits** to edit your selection. Inside a provider, choose **Back to providers** to keep browsing with your selections preserved. Run `loadout` again to change your selection.
17
17
 
18
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
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.
20
+ Press **Esc** from review, questions, or downloads to return to your kits. Inside file details, **Esc** returns to the previous view. Your kit choices, completed answers, and picker position are preserved; completed downloads are reused during the session and file plans are rebuilt before review. In review, **↑/↓** scroll and **Tab** moves between actions. Press **Ctrl+C** to cancel setup, or **Esc twice quickly** from the picker. Nothing is applied until you choose **Apply changes**.
21
21
 
22
22
  Conflicting project instructions are skipped with a warning; the kit’s skills still install.
23
23
 
package/dist/cli.js CHANGED
@@ -5,10 +5,9 @@ import { readFileSync, realpathSync } from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import { discover } from './catalog.js';
7
7
  import { initialize } from './init.js';
8
- import { interactive, confirmApply, selectUpdates, confirmRetry, confirmAdoption, } from './interactive.js';
9
- import { apply, applyAll, hasChanges, plan } from './storage.js';
8
+ import { interactive } from './setup.js';
9
+ import { apply, hasChanges, plan } from './storage.js';
10
10
  import { renderWithExternal } from './external.js';
11
- import { DownloadCancelledError } from './retry.js';
12
11
  import { loadTarget } from './targets.js';
13
12
  import { configure, disableKits, reasons, resolveKits, setAnswers, } from './resolve.js';
14
13
  import { kitSource } from './schema.js';
@@ -134,54 +133,9 @@ async function generate(catalog, state, opts) {
134
133
  }
135
134
  async function setup() {
136
135
  const { targets, initial } = interactiveTargets();
137
- await interactive(targets, initial, { review: reviewSelections });
138
- }
139
- async function reviewSelections(configured) {
140
136
  const offline = program.opts().offline;
141
- const plans = [];
142
- for (const { target, state } of configured) {
143
- const catalog = target.catalog;
144
- console.log(`\n${target.label} · ${target.root}`);
145
- const update = await selectUpdates(catalog, state, offline);
146
- const rendered = await renderWithExternal(catalog, state, {
147
- offline,
148
- update,
149
- retry: confirmRetry,
150
- onRetry: (id) => console.log(`Retrying ${id}…`),
151
- onFetch: (id, source) => console.log(`Fetching ${id} from ${source.repo}…`),
152
- });
153
- for (const id of resolveKits(catalog, state.selected)) {
154
- const kit = catalog.kits.get(id);
155
- if (kit.external) {
156
- const source = update.includes(id)
157
- ? kit.external
158
- : (kit.pinned ?? kit.external);
159
- console.log(`${id}: ${source.repo}@${source.ref.slice(0, 12)} · license: ${source.license}`);
160
- }
161
- }
162
- const result = plan(catalog, state, rendered, { adopt: true });
163
- preview(result, update.length > 0 || !!result.adopted?.length);
164
- if (result.adopted?.length && !(await confirmAdoption(result.adopted)))
165
- throw new DownloadCancelledError();
166
- plans.push(result);
167
- }
168
- if (!plans.some(hasChanges))
169
- return;
170
- const scopes = configured
171
- .filter((_, index) => hasChanges(plans[index]))
172
- .map(({ target }) => target.label);
173
- if (await confirmApply(undefined, scopes)) {
174
- applyAll(plans);
175
- console.log('\nYour loadout is ready.');
176
- for (const result of plans) {
177
- if (!result.skippedInstructions?.length)
178
- continue;
179
- console.log(result.root);
180
- showSkipped(result);
181
- }
182
- }
183
- else
184
- console.log('Cancelled. No kit selections or agent outputs saved.');
137
+ await interactive(targets, initial, { offline });
138
+ console.log('\nYour loadout is ready.');
185
139
  }
186
140
  program.action(setup);
187
141
  program
@@ -33,6 +33,7 @@ declare const storeSchema: z.ZodObject<{
33
33
  }, z.core.$strict>>;
34
34
  }, z.core.$strict>;
35
35
  export type Snapshot = z.infer<typeof snapshotSchema>;
36
+ export type SnapshotCache = Map<string, Snapshot>;
36
37
  export type ExternalStore = z.infer<typeof storeSchema>;
37
38
  export type FetchBytes = (url: string, limit: number, signal?: AbortSignal) => Promise<Buffer>;
38
39
  export declare function readExternal(root: string): {
@@ -47,5 +48,8 @@ export declare function renderWithExternal(catalog: Catalog, state: State, optio
47
48
  onFetch?: (id: string, source: ExternalSource) => void;
48
49
  onRetry?: (id: string) => void;
49
50
  retry?: (id: string, error: Error) => Promise<boolean>;
51
+ signal?: AbortSignal;
52
+ cache?: SnapshotCache;
53
+ onReady?: (id: string) => void;
50
54
  }): Promise<Rendered>;
51
55
  export {};
package/dist/external.js CHANGED
@@ -30,6 +30,12 @@ const storeSchema = z
30
30
  kits: z.record(idSchema, snapshotSchema),
31
31
  })
32
32
  .strict();
33
+ const sourceKey = (source) => JSON.stringify({
34
+ repo: source.repo,
35
+ ref: source.ref,
36
+ license: source.license,
37
+ skills: [...source.skills].sort(),
38
+ });
33
39
  export function readExternal(root) {
34
40
  const raw = readOptional(root, '.loadout-personal/external.json');
35
41
  return {
@@ -215,6 +221,7 @@ async function download(source, get) {
215
221
  return complete;
216
222
  }
217
223
  export async function renderWithExternal(catalog, state, options = {}) {
224
+ options.signal?.throwIfAborted();
218
225
  const result = render(catalog, state);
219
226
  const enabled = resolveKits(catalog, state.selected);
220
227
  const { raw, store } = readExternal(catalog.root);
@@ -222,34 +229,46 @@ export async function renderWithExternal(catalog, state, options = {}) {
222
229
  if (!enabled.includes(id) || !catalog.kits.get(id)?.external)
223
230
  throw new Error(`Cannot update ${id}: choose an enabled external kit.`);
224
231
  for (const id of enabled) {
232
+ options.signal?.throwIfAborted();
225
233
  const kit = catalog.kits.get(id);
226
234
  const source = kit.external;
227
- if (!source)
235
+ if (!source) {
236
+ options.onReady?.(id);
228
237
  continue;
238
+ }
229
239
  let snapshot = Object.hasOwn(store.kits, id) ? store.kits[id] : undefined;
230
240
  const unchanged = snapshot && sameSource(snapshot.source, source);
231
241
  if (!snapshot ||
232
242
  (options.update?.includes(id) && !(options.offline && unchanged))) {
233
- if (options.offline)
243
+ const cached = options.cache?.get(sourceKey(source));
244
+ if (!cached && options.offline)
234
245
  throw new Error(`${id} is not available at the requested revision offline. Run without --offline once to fetch it.`);
235
- options.onFetch?.(id, source);
236
- snapshot = await retryDownload(async () => {
237
- const controller = new AbortController();
238
- const requests = new Map();
239
- const get = (url, limit) => {
240
- if (!requests.has(url))
241
- requests.set(url, (options.fetch ?? fetchBytes)(url, limit, controller.signal));
242
- return requests.get(url);
243
- };
244
- try {
245
- return await download(source, get);
246
- }
247
- finally {
248
- controller.abort();
249
- await Promise.allSettled(requests.values());
250
- requests.clear();
251
- }
252
- }, options.retry ? (error) => options.retry(id, error) : undefined, () => options.onRetry?.(id));
246
+ if (!cached)
247
+ options.onFetch?.(id, source);
248
+ snapshot =
249
+ cached ??
250
+ (await retryDownload(async () => {
251
+ const controller = new AbortController();
252
+ const requests = new Map();
253
+ const get = (url, limit) => {
254
+ if (!requests.has(url))
255
+ requests.set(url, (options.fetch ?? fetchBytes)(url, limit, options.signal
256
+ ? AbortSignal.any([options.signal, controller.signal])
257
+ : controller.signal));
258
+ return requests.get(url);
259
+ };
260
+ try {
261
+ return await download(source, get);
262
+ }
263
+ finally {
264
+ controller.abort();
265
+ await Promise.allSettled(requests.values());
266
+ requests.clear();
267
+ }
268
+ }, options.retry ? (error) => options.retry(id, error) : undefined, () => options.onRetry?.(id), options.signal));
269
+ options.signal?.throwIfAborted();
270
+ validateSnapshot(snapshot, id);
271
+ options.cache?.set(sourceKey(source), snapshot);
253
272
  store.kits[id] = snapshot;
254
273
  }
255
274
  validateSnapshot(snapshot, id);
@@ -271,6 +290,7 @@ export async function renderWithExternal(catalog, state, options = {}) {
271
290
  });
272
291
  }
273
292
  }
293
+ options.onReady?.(id);
274
294
  }
275
295
  if (raw || Object.keys(store.kits).length)
276
296
  result.external = {
@@ -1,15 +1,16 @@
1
1
  import { checkbox, confirm, select } from '@inquirer/prompts';
2
- import { type Catalog, type State } from './schema.js';
3
- import { type TargetSelection } from './picker.js';
2
+ import { type Answer, type Catalog, type State } from './schema.js';
4
3
  import { type Target } from './targets.js';
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[]>;
10
- export declare function configureSelection(target: Target, state: State, context?: Parameters<typeof confirm>[1]): Promise<State>;
4
+ export type PromptContext = NonNullable<Parameters<typeof confirm>[1]>;
5
+ export declare class BackNavigation extends Error {
6
+ }
7
+ export declare function configureSelection(target: Target, state: State, context?: Parameters<typeof confirm>[1], onAnswer?: (kit: string, key: string, answer: Answer) => void): Promise<State>;
11
8
  export declare function confirmApply(context?: PromptContext, scopes?: string[]): Promise<boolean>;
12
- export declare function selectUpdates(catalog: Catalog, state: State, offline?: boolean, context?: Parameters<typeof checkbox>[1]): Promise<string[]>;
9
+ export declare function selectUpdates(catalog: Catalog, state: State, offline?: boolean, context?: Parameters<typeof checkbox>[1], options?: {
10
+ message?: string;
11
+ selected?: string[];
12
+ quiet?: boolean;
13
+ }): Promise<string[]>;
13
14
  export declare function confirmRetry(id: string, error: Error, context?: Parameters<typeof select>[1]): Promise<boolean>;
14
- export declare function confirmAdoption(paths: string[], context?: Parameters<typeof confirm>[1]): Promise<boolean>;
15
- export {};
15
+ export declare function confirmAdoption(paths: string[], context?: Parameters<typeof confirm>[1], scope?: string): Promise<boolean>;
16
+ export declare function retryReview(error: unknown, context?: PromptContext): Promise<boolean>;
@@ -1,10 +1,9 @@
1
1
  import { checkbox, confirm, select } from '@inquirer/prompts';
2
2
  import { configure } from './resolve.js';
3
- import { validAnswer } from './schema.js';
4
- import { targetPicker, } from './picker.js';
3
+ import { validAnswer, } from './schema.js';
5
4
  import { availableUpdates, updateDescription } from './updates.js';
6
5
  import { prepareInput } from './terminal.js';
7
- class BackToPicker extends Error {
6
+ export class BackNavigation extends Error {
8
7
  }
9
8
  async function backPrompt(prompt, context = {}) {
10
9
  const input = context.input ?? process.stdin;
@@ -12,7 +11,7 @@ async function backPrompt(prompt, context = {}) {
12
11
  const controller = new AbortController();
13
12
  const back = (_text, key) => {
14
13
  if (key.name === 'escape')
15
- controller.abort(new BackToPicker());
14
+ controller.abort(new BackNavigation());
16
15
  };
17
16
  input.on('keypress', back);
18
17
  try {
@@ -24,7 +23,7 @@ async function backPrompt(prompt, context = {}) {
24
23
  });
25
24
  }
26
25
  catch (error) {
27
- if (error instanceof Error && error.cause instanceof BackToPicker)
26
+ if (error instanceof Error && error.cause instanceof BackNavigation)
28
27
  throw error.cause;
29
28
  throw error;
30
29
  }
@@ -32,30 +31,9 @@ async function backPrompt(prompt, context = {}) {
32
31
  input.off('keypress', back);
33
32
  }
34
33
  }
35
- export async function interactive(targets, initial = 0, options = {}) {
36
- if (!options.context && (!process.stdin.isTTY || !process.stdout.isTTY))
37
- throw new Error('Interactive setup needs a terminal. Use loadout enable <kit>, then loadout apply.');
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
- }
54
- }
55
- }
56
- export async function configureSelection(target, state, context) {
34
+ export async function configureSelection(target, state, context, onAnswer) {
57
35
  const change = new Map();
58
- return configure(target.catalog, state, async (kit, key, question, value) => {
36
+ const ask = async (kit, key, question, value) => {
59
37
  if (!change.has(kit)) {
60
38
  const hasSaved = Object.entries(target.catalog.kits.get(kit).questions).some(([name, q]) => validAnswer(q, state.answers[kit]?.[name]));
61
39
  change.set(kit, hasSaved
@@ -76,6 +54,11 @@ export async function configureSelection(target, state, context) {
76
54
  choices: question.choices.map((v) => ({ name: v, value: v })),
77
55
  default: typeof value === 'string' ? value : undefined,
78
56
  }, context), context);
57
+ };
58
+ return configure(target.catalog, state, async (kit, key, question, value) => {
59
+ const answer = await ask(kit, key, question, value);
60
+ onAnswer?.(kit, key, answer);
61
+ return answer;
79
62
  });
80
63
  }
81
64
  export async function confirmApply(context, scopes = []) {
@@ -86,20 +69,23 @@ export async function confirmApply(context, scopes = []) {
86
69
  default: true,
87
70
  }, context), context);
88
71
  }
89
- export async function selectUpdates(catalog, state, offline = false, context) {
72
+ export async function selectUpdates(catalog, state, offline = false, context, options = {}) {
90
73
  const updates = availableUpdates(catalog, state.selected);
91
74
  if (!updates.length)
92
75
  return [];
93
76
  if (offline) {
94
- console.log(`${updates.length} catalog update(s) available. Run loadout online to review them; keeping saved versions.`);
77
+ if (!options.quiet)
78
+ console.log(`${updates.length} catalog update(s) available. Run loadout online to review them; keeping saved versions.`);
95
79
  return [];
96
80
  }
97
81
  return backPrompt((context) => checkbox({
98
- message: 'Catalog updates available · choose kits to update (Enter skips)',
82
+ message: options.message ??
83
+ 'Catalog updates available · choose kits to update (Enter skips)',
99
84
  choices: updates.map((kit) => ({
100
85
  name: kit.id,
101
86
  value: kit.id,
102
87
  description: updateDescription(kit),
88
+ checked: options.selected?.includes(kit.id) ?? false,
103
89
  })),
104
90
  required: false,
105
91
  }, context), context);
@@ -113,9 +99,25 @@ export async function confirmRetry(id, error, context) {
113
99
  ],
114
100
  }, context), context);
115
101
  }
116
- export async function confirmAdoption(paths, context) {
102
+ export async function confirmAdoption(paths, context, scope) {
117
103
  return backPrompt((context) => confirm({
118
- message: `Keep existing content and let Loadout manage ${paths.join(', ')}? Originals will be restored when disabled.`,
104
+ message: `${scope ? `${scope} · ` : ''}Keep existing content and let Loadout manage ${paths.join(', ')}? Originals will be restored when disabled.`,
119
105
  default: true,
120
106
  }, context), context);
121
107
  }
108
+ export async function retryReview(error, context) {
109
+ try {
110
+ return await backPrompt((context) => select({
111
+ message: `Could not finish: ${error instanceof Error ? error.message : String(error)}`,
112
+ choices: [
113
+ { name: 'Try again', value: true },
114
+ { name: 'Back to kits', value: false },
115
+ ],
116
+ }, context), context);
117
+ }
118
+ catch (failure) {
119
+ if (failure instanceof BackNavigation)
120
+ return false;
121
+ throw failure;
122
+ }
123
+ }
@@ -0,0 +1,10 @@
1
+ import { type PromptContext } from './interactive.js';
2
+ import { type ReviewTarget, type PreparedTarget } from './review.js';
3
+ type Progress = (scope: number, message: string) => void;
4
+ type Retry = (scope: number, id: string, error: Error) => Promise<boolean>;
5
+ type Config = {
6
+ selections: ReviewTarget[];
7
+ run: (progress: Progress, retry: Retry, signal: AbortSignal) => Promise<PreparedTarget[]>;
8
+ };
9
+ export declare function prepareScreen(config: Config, context?: PromptContext): Promise<PreparedTarget[]>;
10
+ export {};
@@ -0,0 +1,92 @@
1
+ import { createPrompt, isEnterKey, useEffect, useKeypress, useRef, useState, } from '@inquirer/core';
2
+ import { styleText } from 'node:util';
3
+ import { BackNavigation } from './interactive.js';
4
+ import { wrapReviewText, } from './review.js';
5
+ // A single live screen owns all network activity, including retry decisions.
6
+ const progressScreen = createPrompt((config, done) => {
7
+ const [statuses, setStatuses] = useState(config.selections.map(() => 'Waiting'));
8
+ const [failure, setFailure] = useState();
9
+ const [active, setActive] = useState(0);
10
+ const [visible, setVisible] = useState(false);
11
+ const controller = useRef(new AbortController());
12
+ const pending = useRef(undefined);
13
+ useEffect(() => {
14
+ let mounted = true;
15
+ // Cached and local kits go straight to review without flashing a screen.
16
+ const timer = setTimeout(() => setVisible(true), 150);
17
+ const signal = controller.current.signal;
18
+ const progress = (scope, message) => {
19
+ if (mounted)
20
+ setStatuses((previous) => previous.map((value, index) => (index === scope ? message : value)));
21
+ };
22
+ const retry = (scope, id, error) => new Promise((resolve, reject) => {
23
+ signal.throwIfAborted();
24
+ const abort = () => {
25
+ pending.current = undefined;
26
+ reject(signal.reason);
27
+ };
28
+ signal.addEventListener('abort', abort, { once: true });
29
+ pending.current = (value) => {
30
+ signal.removeEventListener('abort', abort);
31
+ pending.current = undefined;
32
+ setFailure(undefined);
33
+ resolve(value);
34
+ };
35
+ setActive(0);
36
+ setFailure({ scope, id, error });
37
+ });
38
+ void config.run(progress, retry, signal).then((prepared) => {
39
+ if (mounted)
40
+ done({ prepared });
41
+ }, (error) => {
42
+ if (mounted)
43
+ done({ error });
44
+ });
45
+ return () => {
46
+ mounted = false;
47
+ clearTimeout(timer);
48
+ controller.current.abort(new BackNavigation());
49
+ };
50
+ }, []);
51
+ useKeypress((key) => {
52
+ if (key.name === 'escape') {
53
+ controller.current.abort(new BackNavigation());
54
+ done({ error: new BackNavigation() });
55
+ }
56
+ else if (failure) {
57
+ if (key.name === 'up' || key.name === 'down')
58
+ setActive(active === 0 ? 1 : 0);
59
+ else if (isEnterKey(key)) {
60
+ if (active === 0)
61
+ pending.current?.(true);
62
+ else {
63
+ controller.current.abort(new BackNavigation());
64
+ done({ error: new BackNavigation() });
65
+ }
66
+ }
67
+ }
68
+ });
69
+ const width = Math.max(20, (process.stdout.columns || 80) - 4);
70
+ if (!visible && !failure)
71
+ return '';
72
+ return [
73
+ ...config.selections.map(({ target }, index) => ` ${styleText(target.global ? 'magenta' : 'cyan', target.label)} · ${wrapReviewText(statuses[index], width - target.label.length - 3)[0]}`),
74
+ ...(failure
75
+ ? [
76
+ ...wrapReviewText(`${config.selections[failure.scope].target.label} · ${failure.id}: ${failure.error.message}`, width).map((line) => ` ${line}`),
77
+ ` ${active === 0 ? '›' : ' '} [ Retry download ]`,
78
+ ` ${active === 1 ? '›' : ' '} [ Back to kits ]`,
79
+ ]
80
+ : []),
81
+ ' Esc back · Ctrl+C cancel',
82
+ ].join('\n');
83
+ });
84
+ export async function prepareScreen(config, context) {
85
+ const result = await progressScreen(config, {
86
+ ...context,
87
+ clearPromptOnDone: true,
88
+ });
89
+ if ('error' in result)
90
+ throw result.error;
91
+ return result.prepared;
92
+ }
package/dist/retry.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export declare class DownloadCancelledError extends Error {
2
2
  constructor();
3
3
  }
4
- export declare function retryDownload<T>(attempt: () => Promise<T>, retry?: (error: Error) => Promise<boolean>, onRetry?: () => void): Promise<T>;
4
+ export declare function retryDownload<T>(attempt: () => Promise<T>, retry?: (error: Error) => Promise<boolean>, onRetry?: () => void, signal?: AbortSignal): Promise<T>;
package/dist/retry.js CHANGED
@@ -4,13 +4,15 @@ export class DownloadCancelledError extends Error {
4
4
  this.name = 'DownloadCancelledError';
5
5
  }
6
6
  }
7
- export async function retryDownload(attempt, retry, onRetry) {
7
+ export async function retryDownload(attempt, retry, onRetry, signal) {
8
8
  for (;;) {
9
9
  for (let tries = 0; tries < 2; tries++) {
10
10
  try {
11
+ signal?.throwIfAborted();
11
12
  return await attempt();
12
13
  }
13
14
  catch (failure) {
15
+ signal?.throwIfAborted();
14
16
  const error = failure instanceof Error ? failure : new Error(String(failure));
15
17
  if (tries === 0) {
16
18
  onRetry?.();
@@ -0,0 +1,35 @@
1
+ import { type TargetSelection } from './picker.js';
2
+ import { type PromptContext } from './interactive.js';
3
+ import { type Change, type Plan } from './storage.js';
4
+ export type ReviewTarget = TargetSelection & {
5
+ update: string[];
6
+ plan?: Plan;
7
+ };
8
+ export type PreparedTarget = ReviewTarget & {
9
+ plan: Plan;
10
+ };
11
+ export type ReviewAction = 'back' | 'apply' | 'done';
12
+ type Row = {
13
+ text: string;
14
+ scope?: number;
15
+ heading?: boolean;
16
+ warning?: boolean;
17
+ expand?: number;
18
+ file?: Change;
19
+ };
20
+ export type KitReview = {
21
+ id: string;
22
+ effect: 'Add' | 'Remove' | 'Update' | 'Configure' | 'Keep';
23
+ notes: string[];
24
+ unchanged: boolean;
25
+ };
26
+ export declare function selectionSummary({ target, state, update, }: ReviewTarget): KitReview[];
27
+ export declare function reviewRows(selections: ReviewTarget[], expanded: number[]): Row[];
28
+ export declare function wrapReviewText(value: string, width: number): string[];
29
+ type ReviewConfig = {
30
+ selections: ReviewTarget[];
31
+ columns?: number;
32
+ rows?: number;
33
+ };
34
+ export declare function reviewScreen(config: ReviewConfig, context?: PromptContext): Promise<ReviewAction>;
35
+ export {};
package/dist/review.js ADDED
@@ -0,0 +1,346 @@
1
+ import { createPrompt, isEnterKey, isSpaceKey, useEffect, useKeypress, useState, } from '@inquirer/core';
2
+ import { createTwoFilesPatch } from 'diff';
3
+ import { stripVTControlCharacters, styleText } from 'node:util';
4
+ import stringWidth from 'string-width';
5
+ import { kitSource } from './schema.js';
6
+ import { reasons, resolveKits } from './resolve.js';
7
+ import { hasChanges } from './storage.js';
8
+ import { suspendEscapeCancellation } from './terminal.js';
9
+ // Compare effective kits, not just checkboxes: a deselected dependency may stay enabled.
10
+ export function selectionSummary({ target, state, update, }) {
11
+ const catalog = target.catalog;
12
+ const saved = target.state ?? { schemaVersion: 1, selected: [], answers: {} };
13
+ const old = new Set([
14
+ ...resolveKits(catalog, saved.selected.filter((id) => catalog.kits.has(id) && catalog.kits.get(id)?.ready !== false)),
15
+ ...saved.selected,
16
+ ]);
17
+ const next = new Set(resolveKits(catalog, state.selected));
18
+ return [...new Set([...old, ...next])].sort().map((id) => {
19
+ const kit = catalog.kits.get(id);
20
+ const explicitChanged = saved.selected.includes(id) !== state.selected.includes(id);
21
+ const answerKeys = Object.keys(kit?.questions ?? {}).sort();
22
+ const configured = next.has(id) &&
23
+ answerKeys.some((key) => saved.answers[id]?.[key] !== state.answers[id]?.[key]);
24
+ const effect = !next.has(id)
25
+ ? 'Remove'
26
+ : !old.has(id)
27
+ ? 'Add'
28
+ : update.includes(id)
29
+ ? 'Update'
30
+ : configured
31
+ ? 'Configure'
32
+ : 'Keep';
33
+ const notes = [];
34
+ if (next.has(id) && !state.selected.includes(id))
35
+ notes.push(`${saved.selected.includes(id) ? 'still required' : 'dependency'} of ${reasons(catalog, state.selected, id).join(', ')}`);
36
+ else if (effect === 'Keep' && explicitChanged)
37
+ notes.push('chosen directly');
38
+ if (effect === 'Update' && kit?.external)
39
+ notes.push(`${kit.pinned?.ref.slice(0, 7) ?? 'saved'} → ${kit.external.ref.slice(0, 7)}`);
40
+ if (next.has(id))
41
+ for (const key of answerKeys) {
42
+ const answer = state.answers[id]?.[key];
43
+ if (answer !== undefined)
44
+ notes.push(`${key === 'placement' ? 'Install as' : key.replaceAll('-', ' ')}: ${answer}`);
45
+ }
46
+ if (kit && kitSource(kit) !== 'Repository')
47
+ notes.push(kitSource(kit));
48
+ return {
49
+ id,
50
+ effect,
51
+ notes,
52
+ unchanged: effect === 'Keep' && !explicitChanged,
53
+ };
54
+ });
55
+ }
56
+ const outputChanges = (plan) => plan.changes.filter((change) => change.kind !== 'unchanged' &&
57
+ !change.path.startsWith('.loadout-personal/'));
58
+ export function reviewRows(selections, expanded) {
59
+ const rows = [];
60
+ const summaries = selections.map(selectionSummary);
61
+ if (!selections.some(({ plan }) => plan && hasChanges(plan)))
62
+ rows.push({ text: 'No changes to apply' });
63
+ selections.forEach(({ target, plan }, scope) => {
64
+ const kits = summaries[scope];
65
+ rows.push({
66
+ text: `${target.label} · ${target.root}`,
67
+ scope,
68
+ heading: true,
69
+ });
70
+ const changed = kits.filter((kit) => !kit.unchanged);
71
+ const unchanged = kits.filter((kit) => kit.unchanged);
72
+ const kitRow = (kit) => ({
73
+ text: ` ${kit.effect.padEnd(9)} ${kit.id}${kit.notes.length ? ` · ${kit.notes.join(' · ')}` : ''}`,
74
+ scope,
75
+ });
76
+ rows.push(...changed.map(kitRow));
77
+ if (unchanged.length) {
78
+ rows.push({
79
+ text: ` ${expanded.includes(scope) ? '▾' : '▸'} Unchanged (${unchanged.length})`,
80
+ scope,
81
+ expand: scope,
82
+ });
83
+ if (expanded.includes(scope))
84
+ rows.push(...unchanged.map(kitRow));
85
+ }
86
+ if (!kits.length)
87
+ rows.push({ text: ' No kits selected', scope });
88
+ if (plan) {
89
+ const changes = outputChanges(plan);
90
+ const counts = ['create', 'update', 'delete'].flatMap((kind) => {
91
+ const count = changes.filter((change) => change.kind === kind).length;
92
+ return count
93
+ ? [`${count} ${kind === 'delete' ? 'remove' : kind}`]
94
+ : [];
95
+ });
96
+ if (counts.length)
97
+ rows.push({ text: ` Files: ${counts.join(' · ')}`, scope });
98
+ else if (hasChanges(plan))
99
+ rows.push({ text: ' Settings only', scope });
100
+ if (plan.adopted?.length)
101
+ rows.push({
102
+ text: ` Existing content kept: ${plan.adopted.join(', ')}`,
103
+ scope,
104
+ });
105
+ for (const skipped of plan.skippedInstructions ?? [])
106
+ rows.push({
107
+ text: ` Skipped instructions · ${skipped.kits.join(', ')}: ${skipped.reason} (${skipped.paths.join(', ')})`,
108
+ scope,
109
+ warning: true,
110
+ });
111
+ for (const id of plan.kitsWithoutOutputs ?? [])
112
+ rows.push({
113
+ text: ` ${id}: no agent outputs will be applied`,
114
+ scope,
115
+ warning: true,
116
+ });
117
+ }
118
+ rows.push({ text: '' });
119
+ });
120
+ return rows;
121
+ }
122
+ function fileRows(selections) {
123
+ return selections.flatMap(({ target, state, update, plan }, scope) => {
124
+ const rows = [
125
+ { text: `${target.label} · ${target.root}`, scope, heading: true },
126
+ ];
127
+ for (const id of resolveKits(target.catalog, state.selected)) {
128
+ const kit = target.catalog.kits.get(id);
129
+ if (!kit.external)
130
+ continue;
131
+ const source = update.includes(id)
132
+ ? kit.external
133
+ : (kit.pinned ?? kit.external);
134
+ rows.push({
135
+ text: `${id}: ${source.repo}@${source.ref} · license: ${source.license}`,
136
+ scope,
137
+ });
138
+ }
139
+ const files = plan ? outputChanges(plan) : [];
140
+ rows.push(...files.map((file) => ({
141
+ text: `${file.kind === 'create' ? '+' : file.kind === 'delete' ? '-' : '~'} ${file.path}`,
142
+ scope,
143
+ file,
144
+ })));
145
+ if (!files.length)
146
+ rows.push({ text: 'No agent file changes', scope });
147
+ rows.push({ text: '' });
148
+ return rows;
149
+ });
150
+ }
151
+ function diffRows(row) {
152
+ const file = row.file;
153
+ const before = file.before?.content ?? Buffer.alloc(0);
154
+ const after = file.after?.content ?? Buffer.alloc(0);
155
+ const header = { text: file.path, scope: row.scope, heading: true };
156
+ if (before.equals(after))
157
+ return [header, { text: 'File mode changed; content is unchanged.' }];
158
+ if (before.length + after.length > 200_000 ||
159
+ before.includes(0) ||
160
+ after.includes(0))
161
+ return [
162
+ header,
163
+ { text: 'Binary or large content; inspect the source for details.' },
164
+ ];
165
+ return [
166
+ header,
167
+ ...createTwoFilesPatch(`a/${file.path}`, `b/${file.path}`, before.toString('utf8'), after.toString('utf8'), undefined, undefined, { context: 3 })
168
+ .split('\n')
169
+ .map((text) => ({ text, scope: row.scope })),
170
+ ];
171
+ }
172
+ export function wrapReviewText(value, width) {
173
+ const text = stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f]/g, ' ');
174
+ const lines = [];
175
+ let line = '';
176
+ for (const character of text) {
177
+ if (line && stringWidth(line + character) > width) {
178
+ lines.push(line);
179
+ line = '';
180
+ }
181
+ line += character;
182
+ }
183
+ lines.push(line);
184
+ return lines;
185
+ }
186
+ const screen = createPrompt((config, done) => {
187
+ const [expanded, setExpanded] = useState([]);
188
+ const [view, setView] = useState('summary');
189
+ const [diff, setDiff] = useState([]);
190
+ const [active, setActive] = useState(undefined);
191
+ const [lastContent, setLastContent] = useState(0);
192
+ const [summaryCursor, setSummaryCursor] = useState(0);
193
+ const [fileCursor, setFileCursor] = useState(0);
194
+ const [size, setSize] = useState({
195
+ columns: process.stdout.columns || 80,
196
+ rows: process.stdout.rows || 24,
197
+ });
198
+ useEffect(() => {
199
+ const resize = () => setSize({
200
+ columns: process.stdout.columns || 80,
201
+ rows: process.stdout.rows || 24,
202
+ });
203
+ process.stdout.on('resize', resize);
204
+ return () => {
205
+ process.stdout.off('resize', resize);
206
+ };
207
+ }, []);
208
+ const width = Math.max(16, (config.columns ?? size.columns) - 4);
209
+ const changed = config.selections.some(({ plan }) => plan && hasChanges(plan));
210
+ const actions = view !== 'summary'
211
+ ? [
212
+ {
213
+ label: view === 'diff' ? 'Back to files' : 'Back to review',
214
+ value: 'close',
215
+ },
216
+ ]
217
+ : [
218
+ { label: 'Back to kits', value: 'back' },
219
+ { label: 'View files', value: 'files' },
220
+ {
221
+ label: changed ? 'Apply changes' : 'Done',
222
+ value: changed ? 'apply' : 'done',
223
+ },
224
+ ];
225
+ const content = view === 'diff'
226
+ ? diff
227
+ : view === 'files'
228
+ ? fileRows(config.selections)
229
+ : reviewRows(config.selections, expanded);
230
+ const rows = content.flatMap((row) => wrapReviewText(row.text, width - 2).map((text, index) => ({
231
+ ...row,
232
+ text,
233
+ ...(index ? { expand: undefined, file: undefined } : {}),
234
+ })));
235
+ const cursor = Math.min(active ?? rows.length + actions.length - 1, rows.length + actions.length - 1);
236
+ const focus = (index) => {
237
+ setActive(index);
238
+ if (index < rows.length)
239
+ setLastContent(index);
240
+ };
241
+ const pageSize = Math.min(rows.length, Math.max(2, (config.rows ?? size.rows) - actions.length - 5));
242
+ const start = Math.max(0, Math.min((cursor < rows.length ? cursor : lastContent) - Math.floor(pageSize / 2), rows.length - pageSize));
243
+ const back = () => {
244
+ if (view === 'diff') {
245
+ setView('files');
246
+ setActive(fileCursor);
247
+ }
248
+ else if (view === 'files') {
249
+ setView('summary');
250
+ setActive(summaryCursor);
251
+ }
252
+ else
253
+ done('back');
254
+ };
255
+ useKeypress((key, rl) => {
256
+ rl.clearLine(0);
257
+ if (key.name === 'escape') {
258
+ back();
259
+ return;
260
+ }
261
+ if (key.name === 'tab') {
262
+ const index = cursor < rows.length
263
+ ? key.shift
264
+ ? actions.length
265
+ : -1
266
+ : cursor - rows.length;
267
+ setActive(rows.length +
268
+ ((index + (key.shift ? -1 : 1) + actions.length) % actions.length));
269
+ }
270
+ else if (key.name === 'home')
271
+ focus(0);
272
+ else if (key.name === 'end')
273
+ setActive(rows.length + actions.length - 1);
274
+ else if (['up', 'down', 'pageup', 'pagedown'].includes(key.name)) {
275
+ const step = key.name.startsWith('page') ? pageSize : 1;
276
+ const backwards = key.name === 'up' || key.name === 'pageup';
277
+ const length = rows.length + actions.length;
278
+ focus((cursor + ((backwards ? -step : step) % length) + length) % length);
279
+ }
280
+ else if (isEnterKey(key) || isSpaceKey(key)) {
281
+ const action = actions[cursor - rows.length];
282
+ if (action?.value === 'close')
283
+ back();
284
+ else if (action?.value === 'files') {
285
+ setSummaryCursor(cursor);
286
+ setView('files');
287
+ setActive(0);
288
+ setLastContent(0);
289
+ }
290
+ else if (action)
291
+ done(action.value);
292
+ else {
293
+ const row = rows[cursor];
294
+ if (row?.expand !== undefined)
295
+ setExpanded(expanded.includes(row.expand)
296
+ ? expanded.filter((scope) => scope !== row.expand)
297
+ : [...expanded, row.expand]);
298
+ if (row?.file) {
299
+ setFileCursor(cursor);
300
+ setDiff(diffRows(row));
301
+ setView('diff');
302
+ setActive(0);
303
+ setLastContent(0);
304
+ }
305
+ }
306
+ }
307
+ });
308
+ const accent = (text) => styleText(config.selections.every(({ target }) => target.global)
309
+ ? 'magenta'
310
+ : 'cyan', text);
311
+ const title = view === 'files'
312
+ ? 'Files · Enter to inspect a diff'
313
+ : view === 'diff'
314
+ ? 'File diff'
315
+ : 'Review changes';
316
+ const page = rows.slice(start, start + pageSize).map((row, index) => {
317
+ const global = row.scope !== undefined && config.selections[row.scope]?.target.global;
318
+ const text = row.warning
319
+ ? styleText('yellow', row.text)
320
+ : row.heading
321
+ ? styleText(global ? 'magenta' : 'cyan', styleText('bold', row.text))
322
+ : row.text;
323
+ return ` ${start + index === cursor ? styleText(global ? 'magenta' : 'cyan', '›') : ' '} ${text}`;
324
+ });
325
+ return [
326
+ ` ${accent(styleText('bold', title))}`,
327
+ '',
328
+ ...page,
329
+ ...actions.map((action, index) => ` ${cursor === rows.length + index ? accent('›') : ' '} ${cursor === rows.length + index ? accent(`[ ${action.label} ]`) : `[ ${action.label} ]`}`),
330
+ ` ${styleText('dim', `↑↓ move · Tab actions · Enter select · Esc back${rows.length > pageSize ? ` · ${start + 1}–${Math.min(start + pageSize, rows.length)}/${rows.length}` : ''}`)}`,
331
+ '\u001b[?25l',
332
+ ]
333
+ .map((line) => stringWidth(line) > width + 4
334
+ ? wrapReviewText(line, width + 4)[0]
335
+ : line)
336
+ .join('\n');
337
+ });
338
+ export async function reviewScreen(config, context) {
339
+ const restore = suspendEscapeCancellation(context?.input ?? process.stdin);
340
+ try {
341
+ return await screen(config, { ...context, clearPromptOnDone: true });
342
+ }
343
+ finally {
344
+ restore();
345
+ }
346
+ }
@@ -0,0 +1,9 @@
1
+ import { type TargetSelection } from './picker.js';
2
+ import { type PromptContext } from './interactive.js';
3
+ import { type FetchBytes } from './external.js';
4
+ import { type Target } from './targets.js';
5
+ export declare function interactive(targets: Target[], initial?: number, options?: {
6
+ context?: PromptContext;
7
+ offline?: boolean;
8
+ fetch?: FetchBytes;
9
+ }): Promise<TargetSelection[]>;
package/dist/setup.js ADDED
@@ -0,0 +1,120 @@
1
+ import { targetPicker, } from './picker.js';
2
+ import { BackNavigation, configureSelection, confirmAdoption, selectUpdates, retryReview, } from './interactive.js';
3
+ import { renderWithExternal, } from './external.js';
4
+ import { applyAll, plan } from './storage.js';
5
+ import { reviewScreen, } from './review.js';
6
+ import { prepareScreen } from './prepare.js';
7
+ import { suspendEscapeCancellation } from './terminal.js';
8
+ export async function interactive(targets, initial = 0, options = {}) {
9
+ if (!options.context && (!process.stdin.isTTY || !process.stdout.isTTY))
10
+ throw new Error('Interactive setup needs a terminal. Use loadout enable <kit>, then loadout apply.');
11
+ const session = {};
12
+ const drafts = new Map();
13
+ const cache = new Map();
14
+ const context = { ...options.context, clearPromptOnDone: true };
15
+ for (;;) {
16
+ const chosen = await targetPicker({ targets, initial, session }, context);
17
+ const selections = chosen.map(({ target, state }) => {
18
+ const previous = drafts.get(target);
19
+ const draft = {
20
+ target,
21
+ state: structuredClone({
22
+ ...state,
23
+ answers: previous?.state.answers ?? state.answers,
24
+ }),
25
+ update: previous?.update ?? [],
26
+ };
27
+ drafts.set(target, draft);
28
+ return draft;
29
+ });
30
+ for (const [target, draft] of drafts) {
31
+ if (selections.some((selection) => selection.target === target))
32
+ continue;
33
+ // The picker omits inactive scopes whose checkboxes match saved state.
34
+ // They can still have draft answers, but must use the latest checkboxes.
35
+ draft.state.selected = [
36
+ ...(session.snapshot?.selections[targets.indexOf(target)] ??
37
+ draft.state.selected),
38
+ ];
39
+ if (draft.update.length ||
40
+ JSON.stringify(draft.state.answers) !==
41
+ JSON.stringify(target.state?.answers ?? {}))
42
+ selections.push(draft);
43
+ }
44
+ selections.sort((a, b) => targets.indexOf(a.target) - targets.indexOf(b.target));
45
+ const restoreEscape = suspendEscapeCancellation(context.input ?? process.stdin);
46
+ try {
47
+ for (;;) {
48
+ try {
49
+ // Finish questions for every destination before starting any downloads.
50
+ for (const selection of selections) {
51
+ const { target, state } = selection;
52
+ selection.state = await configureSelection(target, state, context, (kit, key, answer) => {
53
+ state.answers[kit] ??= {};
54
+ state.answers[kit][key] = answer;
55
+ });
56
+ selection.update = await selectUpdates(target.catalog, selection.state, options.offline, context, {
57
+ message: `${target.label} · Choose catalog updates (Enter keeps your choices)`,
58
+ selected: selection.update,
59
+ quiet: true,
60
+ });
61
+ }
62
+ const prepared = await prepareScreen({
63
+ selections,
64
+ run: async (progress, retry, signal) => {
65
+ const prepared = [];
66
+ for (const [scope, selection] of selections.entries()) {
67
+ const { target, state, update } = selection;
68
+ progress(scope, 'Preparing kits…');
69
+ const rendered = await renderWithExternal(target.catalog, state, {
70
+ offline: options.offline,
71
+ fetch: options.fetch,
72
+ update,
73
+ cache,
74
+ signal,
75
+ onFetch: (id) => progress(scope, `Downloading ${id}…`),
76
+ onRetry: (id) => progress(scope, `Retrying ${id}…`),
77
+ onReady: (id) => progress(scope, `${id} · Ready`),
78
+ retry: (id, error) => retry(scope, id, error),
79
+ });
80
+ signal.throwIfAborted();
81
+ progress(scope, 'Checking file changes…');
82
+ prepared.push({
83
+ ...selection,
84
+ plan: plan(target.catalog, state, rendered, {
85
+ adopt: true,
86
+ }),
87
+ });
88
+ progress(scope, 'Ready');
89
+ }
90
+ return prepared;
91
+ },
92
+ }, context);
93
+ for (const { target, plan } of prepared) {
94
+ if (plan.adopted?.length &&
95
+ !(await confirmAdoption(plan.adopted, context, target.label)))
96
+ throw new BackNavigation();
97
+ }
98
+ const action = await reviewScreen({ selections: prepared }, context);
99
+ if (action === 'back')
100
+ break;
101
+ if (action === 'apply')
102
+ applyAll(prepared.map(({ plan }) => plan));
103
+ return selections.map(({ target, state }) => ({ target, state }));
104
+ }
105
+ catch (failure) {
106
+ if (failure instanceof BackNavigation)
107
+ break;
108
+ if (failure instanceof Error &&
109
+ ['ExitPromptError', 'AbortPromptError'].includes(failure.name))
110
+ throw failure;
111
+ if (!(await retryReview(failure, context)))
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ finally {
117
+ restoreEscape();
118
+ }
119
+ }
120
+ }
@@ -1 +1,2 @@
1
+ export declare function suspendEscapeCancellation(input: NodeJS.ReadableStream): () => void;
1
2
  export declare function prepareInput(input: NodeJS.ReadableStream): void;
package/dist/terminal.js CHANGED
@@ -1,6 +1,18 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { Writable } from 'node:stream';
3
- const prepared = new WeakSet();
3
+ const prepared = new WeakMap();
4
+ // Review has a back stack: consecutive Escapes navigate rather than cancel.
5
+ export function suspendEscapeCancellation(input) {
6
+ prepareInput(input);
7
+ const state = prepared.get(input);
8
+ const cancel = state.cancel;
9
+ state.cancel = false;
10
+ state.lastEscape = undefined;
11
+ return () => {
12
+ state.cancel = cancel;
13
+ state.lastEscape = undefined;
14
+ };
15
+ }
4
16
  export function prepareInput(input) {
5
17
  if (prepared.has(input))
6
18
  return;
@@ -15,16 +27,16 @@ export function prepareInput(input) {
15
27
  });
16
28
  decoder.close();
17
29
  output.end();
18
- let lastEscape;
30
+ const state = { cancel: true, lastEscape: undefined };
19
31
  input.on('keypress', (_text, key) => {
20
- if (key.name !== 'escape') {
21
- lastEscape = undefined;
32
+ if (key.name !== 'escape' || !state.cancel) {
33
+ state.lastEscape = undefined;
22
34
  return;
23
35
  }
24
36
  const now = performance.now();
25
37
  if (key.sequence === '\u001b\u001b' ||
26
- (lastEscape !== undefined && now - lastEscape <= 500)) {
27
- lastEscape = undefined;
38
+ (state.lastEscape !== undefined && now - state.lastEscape <= 500)) {
39
+ state.lastEscape = undefined;
28
40
  // Use the same cancellation path as Ctrl+C, including later prompts.
29
41
  input.emit('keypress', '\u0003', {
30
42
  name: 'c',
@@ -35,7 +47,7 @@ export function prepareInput(input) {
35
47
  });
36
48
  }
37
49
  else
38
- lastEscape = now;
50
+ state.lastEscape = now;
39
51
  });
40
- prepared.add(input);
52
+ prepared.set(input, state);
41
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lidtop/loadout",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Choose and manage agent instruction and skill kits for repositories and your home directory.",
5
5
  "type": "module",
6
6
  "bin": {