@lidtop/loadout 0.3.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 +5 -1
- package/dist/cli.js +7 -2
- package/dist/interactive.d.ts +7 -2
- package/dist/interactive.js +66 -25
- package/dist/picker.d.ts +22 -0
- package/dist/picker.js +211 -207
- package/kits/dont-commit/instructions.md +1 -0
- package/kits/dont-commit/kit.yaml +6 -0
- package/kits/write-kit/skills/loadout-write-kit/SKILL.md +3 -1
- package/package.json +1 -1
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 **
|
|
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/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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/interactive.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {};
|
package/dist/interactive.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
initial,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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({
|
|
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 [
|
|
@@ -44,20 +45,33 @@ const providerPrefixes = {
|
|
|
44
45
|
'mattpocock/skills': 'matt-pocock-',
|
|
45
46
|
'anthropics/skills': 'anthropic-',
|
|
46
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
|
+
});
|
|
47
58
|
const emptyState = () => ({
|
|
48
59
|
schemaVersion: 1,
|
|
49
60
|
selected: [],
|
|
50
61
|
answers: {},
|
|
51
62
|
});
|
|
52
63
|
const renderPicker = createPrompt((config, done) => {
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const [
|
|
56
|
-
const [
|
|
57
|
-
|
|
58
|
-
const [
|
|
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)));
|
|
59
72
|
const [notice, setNotice] = useState('');
|
|
60
73
|
const target = targets[targetIndex];
|
|
74
|
+
const accent = (value) => scopeColor(target, value);
|
|
61
75
|
const sections = target.global
|
|
62
76
|
? ['Browse', 'Installed']
|
|
63
77
|
: repositorySections;
|
|
@@ -66,17 +80,18 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
66
80
|
kits: new Map(),
|
|
67
81
|
};
|
|
68
82
|
const selected = selections[targetIndex];
|
|
69
|
-
const
|
|
70
|
-
JSON.stringify([...(
|
|
83
|
+
const pendingTargets = targets.filter((item, index) => JSON.stringify([...selections[index]].sort()) !==
|
|
84
|
+
JSON.stringify([...(item.state?.selected ?? [])].sort()));
|
|
71
85
|
const setSelected = (value) => setSelections(selections.map((ids, index) => (index === targetIndex ? value : ids)));
|
|
72
|
-
const [section, setSection] = useState(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const [
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}, []);
|
|
80
95
|
const [finished, setFinished] = useState(false);
|
|
81
96
|
const [size, setSize] = useState({
|
|
82
97
|
columns: process.stdout.columns || 80,
|
|
@@ -176,10 +191,16 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
176
191
|
.sort((a, b) => Number(a.ready === false) - Number(b.ready === false))
|
|
177
192
|
.map((kit) => ({ id: kit.id, description: kit.description, kit }));
|
|
178
193
|
}
|
|
194
|
+
if (provider)
|
|
195
|
+
entries.push({
|
|
196
|
+
id: '@back',
|
|
197
|
+
action: 'back',
|
|
198
|
+
description: 'Keeps your selections',
|
|
199
|
+
});
|
|
179
200
|
entries.push({
|
|
180
|
-
id: '@
|
|
181
|
-
action: '
|
|
182
|
-
description: '
|
|
201
|
+
id: '@review',
|
|
202
|
+
action: 'review',
|
|
203
|
+
description: '',
|
|
183
204
|
});
|
|
184
205
|
const cursor = Math.min(active, Math.max(0, entries.length - 1));
|
|
185
206
|
const focused = entries[cursor];
|
|
@@ -193,72 +214,77 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
193
214
|
rl.clearLine(0);
|
|
194
215
|
rl.write(query);
|
|
195
216
|
};
|
|
196
|
-
const
|
|
197
|
-
setScopeFocused(false);
|
|
198
|
-
setTargetIndex(index);
|
|
199
|
-
setSection(targets[index].global ||
|
|
200
|
-
![...targets[index].catalog.kits.values()].some((kit) => !providerFor(kit))
|
|
201
|
-
? 'Browse'
|
|
202
|
-
: 'Kits');
|
|
203
|
-
setVisited([...new Set([...visited, index])]);
|
|
217
|
+
const backToProviders = () => {
|
|
204
218
|
setProvider(undefined);
|
|
205
|
-
|
|
206
|
-
|
|
219
|
+
setQuery(providerList.query);
|
|
220
|
+
setActive(providerList.active);
|
|
221
|
+
rl.clearLine(0);
|
|
222
|
+
rl.write(providerList.query);
|
|
207
223
|
};
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
switchTarget(pendingSwitch);
|
|
211
|
-
setPendingSwitch(undefined);
|
|
212
|
-
}
|
|
213
|
-
else if (key.name === 'n' ||
|
|
214
|
-
key.name === 'escape' ||
|
|
215
|
-
isEnterKey(key)) {
|
|
216
|
-
setPendingSwitch(undefined);
|
|
224
|
+
const switchTarget = (index) => {
|
|
225
|
+
if (index === targetIndex) {
|
|
217
226
|
restoreInput();
|
|
227
|
+
return;
|
|
218
228
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const other = targets[next];
|
|
225
|
-
if (other.error)
|
|
226
|
-
setNotice(other.error);
|
|
227
|
-
else if (!other.catalog)
|
|
228
|
-
setNotice('Cannot load this location.');
|
|
229
|
-
else if (hasSelectionChanges) {
|
|
230
|
-
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;
|
|
231
234
|
}
|
|
232
|
-
|
|
233
|
-
|
|
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('');
|
|
234
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);
|
|
235
253
|
return;
|
|
236
254
|
}
|
|
237
|
-
if (['
|
|
238
|
-
const index =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const count = sections.length + (targets.length > 1 ? 1 : 0);
|
|
242
|
-
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';
|
|
243
259
|
const next = (index + (backwards ? -1 : 1) + count) % count;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
setProvider(undefined);
|
|
248
|
-
reset();
|
|
249
|
-
}
|
|
250
|
-
else
|
|
251
|
-
restoreInput();
|
|
260
|
+
setSection(sections[next]);
|
|
261
|
+
setProvider(undefined);
|
|
262
|
+
reset();
|
|
252
263
|
}
|
|
253
264
|
else if (key.name === 'up' || key.name === 'down') {
|
|
254
|
-
setScopeFocused(false);
|
|
255
265
|
if (entries.length)
|
|
256
266
|
setActive((cursor + (key.name === 'up' ? -1 : 1) + entries.length) %
|
|
257
267
|
entries.length);
|
|
258
268
|
restoreInput();
|
|
259
269
|
}
|
|
260
270
|
else if (isEnterKey(key) || isSpaceKey(key)) {
|
|
261
|
-
if (focused?.action === '
|
|
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
|
+
};
|
|
262
288
|
setFinished(true);
|
|
263
289
|
done(targets.flatMap((item, index) => {
|
|
264
290
|
if (!item.catalog || !visited.includes(index))
|
|
@@ -273,6 +299,7 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
273
299
|
}));
|
|
274
300
|
}
|
|
275
301
|
else if (browsingProviders && focused) {
|
|
302
|
+
setProviderList({ query, active: cursor });
|
|
276
303
|
setProvider(focused.id);
|
|
277
304
|
setActive(0);
|
|
278
305
|
}
|
|
@@ -286,124 +313,115 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
286
313
|
rl.write(query);
|
|
287
314
|
}
|
|
288
315
|
else if (key.name === 'escape') {
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
else {
|
|
294
|
-
if (!query && provider)
|
|
295
|
-
setProvider(undefined);
|
|
316
|
+
if (!query && provider)
|
|
317
|
+
backToProviders();
|
|
318
|
+
else
|
|
296
319
|
reset();
|
|
297
|
-
}
|
|
298
320
|
}
|
|
299
321
|
else if (!['left', 'right', 'home', 'end'].includes(key.name)) {
|
|
300
|
-
setScopeFocused(false);
|
|
301
322
|
setQuery(clean(rl.line));
|
|
302
323
|
setActive(0);
|
|
303
324
|
}
|
|
304
325
|
});
|
|
305
|
-
const
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
...scopeLines,
|
|
330
|
-
'',
|
|
331
|
-
` ${bold(fit(`Switch to ${targets[pendingSwitch].label}?`, width - 2))}`,
|
|
332
|
-
...selectionWarning,
|
|
333
|
-
'',
|
|
334
|
-
` ${accent('[Enter/Esc]')} Stay ${accent('[y]')} Switch`,
|
|
335
|
-
'\u001b[?25l',
|
|
336
|
-
].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}`);
|
|
337
350
|
if (finished)
|
|
338
351
|
return [
|
|
339
|
-
...
|
|
352
|
+
...wordmark(width, height, accent),
|
|
340
353
|
` ${accent('✓')} ${selected.length} selected${requiredCount ? muted(` · ${requiredCount} required`) : ''}\n`,
|
|
341
354
|
].join('\n');
|
|
342
355
|
const updates = availableUpdates(catalog).length;
|
|
343
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;
|
|
344
359
|
const tabs = width >= 34
|
|
345
|
-
? ` ${sections
|
|
346
|
-
|
|
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}]`))}`;
|
|
347
369
|
const filter = ` ${accent('/')} ${query ? `${fit(query, width - 6)}${accent('▏')}` : muted(browsingProviders ? 'Search providers or kits' : 'Search kits')}`;
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
`esc ${provider ? 'back' : 'clear'}`,
|
|
361
|
-
];
|
|
362
|
-
const helpLines = [];
|
|
363
|
-
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 = '';
|
|
364
382
|
for (const hint of hints) {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
helpLines[helpLines.length - 1] = combined;
|
|
369
|
-
else
|
|
370
|
-
helpLines.push(fit(hint, width - 2));
|
|
371
|
-
}
|
|
372
|
-
const help = helpLines.map((line) => ` ${muted(line)}`);
|
|
373
|
-
if (scopeFocused && other) {
|
|
374
|
-
const action = other.error || !other.catalog
|
|
375
|
-
? `${other.label} is unavailable`
|
|
376
|
-
: `Switch to ${other.label}`;
|
|
377
|
-
return [
|
|
378
|
-
...header,
|
|
379
|
-
...scopeLines,
|
|
380
|
-
'',
|
|
381
|
-
tabs,
|
|
382
|
-
rule,
|
|
383
|
-
` ${bold(fit(action, width - 2))}`,
|
|
384
|
-
...(other.error ? [` ${fit(other.error, width - 2)}`] : []),
|
|
385
|
-
` ${muted(fit(other.root, width - 2))}`,
|
|
386
|
-
rule,
|
|
387
|
-
...help,
|
|
388
|
-
'\u001b[?25l',
|
|
389
|
-
].join('\n');
|
|
383
|
+
const combined = helpText ? `${helpText} · ${hint}` : hint;
|
|
384
|
+
if (stringWidth(combined) <= width - 2)
|
|
385
|
+
helpText = combined;
|
|
390
386
|
}
|
|
387
|
+
const help = [` ${muted(helpText)}`];
|
|
391
388
|
const beforeList = [
|
|
392
|
-
...header,
|
|
393
389
|
...scopeLines,
|
|
394
|
-
...(scopeLines.length && detailed ? [''] : []),
|
|
395
390
|
tabs,
|
|
396
|
-
...(provider
|
|
397
|
-
|
|
398
|
-
|
|
391
|
+
...(provider && !inlineProvider
|
|
392
|
+
? [` ${muted(fit(provider, width - 2))}`]
|
|
393
|
+
: []),
|
|
399
394
|
filter,
|
|
400
|
-
...(detailed ? [''] : []),
|
|
401
395
|
];
|
|
402
396
|
const summary = fit(`${counts}${updates ? ` · ${updates} updates` : ''}`, width - 6);
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
const
|
|
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)));
|
|
407
425
|
const listEntries = entries.filter((row) => !row.action);
|
|
408
426
|
const listCursor = Math.min(cursor, Math.max(0, listEntries.length - 1));
|
|
409
427
|
const start = Math.max(0, Math.min(listCursor - Math.floor(pageSize / 2), listEntries.length - pageSize));
|
|
@@ -436,28 +454,24 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
436
454
|
? 'Will uninstall'
|
|
437
455
|
: hasUpdate(row.kit)
|
|
438
456
|
? 'update'
|
|
439
|
-
:
|
|
440
|
-
? '
|
|
441
|
-
:
|
|
442
|
-
? '
|
|
443
|
-
:
|
|
444
|
-
? 'saved'
|
|
445
|
-
: '';
|
|
457
|
+
: required
|
|
458
|
+
? 'required'
|
|
459
|
+
: row.kit.pinned && !explicit
|
|
460
|
+
? 'saved'
|
|
461
|
+
: '';
|
|
446
462
|
const marker = !row.kit
|
|
447
463
|
? accent('▸')
|
|
448
464
|
: explicit
|
|
449
465
|
? accent('●')
|
|
450
466
|
: required
|
|
451
467
|
? styleText('yellow', '◆')
|
|
452
|
-
: muted('○');
|
|
468
|
+
: muted(accent('○'));
|
|
453
469
|
const badge = required || willUninstall(row.id) || (row.kit && hasUpdate(row.kit))
|
|
454
470
|
? styleText('yellow', label)
|
|
455
471
|
: muted(label);
|
|
456
472
|
const name = fit(displayName(row.id), width - label.length - 9);
|
|
457
473
|
const gap = ' '.repeat(Math.max(2, width - stringWidth(name) - label.length - 6));
|
|
458
|
-
lines.push(` ${focus ? accent('›') : ' '} ${marker} ${row.kit?.ready === false ? muted(name) : focus ? bold(name) : name}${gap}${badge}`);
|
|
459
|
-
if (detailed)
|
|
460
|
-
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))}`);
|
|
461
475
|
}
|
|
462
476
|
if (!page.length) {
|
|
463
477
|
const empty = !catalog.kits.size
|
|
@@ -465,48 +479,38 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
465
479
|
: query
|
|
466
480
|
? 'No matching kits. Esc to clear.'
|
|
467
481
|
: section === 'Kits'
|
|
468
|
-
? 'No repository kits.
|
|
482
|
+
? 'No repository kits. ←→ to browse.'
|
|
469
483
|
: section === 'Installed'
|
|
470
|
-
? 'No kits installed.
|
|
484
|
+
? 'No kits installed. ←→ to browse.'
|
|
471
485
|
: 'No external providers configured.';
|
|
472
486
|
lines.push(` ${muted(fit(empty, width - 2))}`);
|
|
473
487
|
}
|
|
474
|
-
const kit = focused?.kit;
|
|
475
|
-
const why = kit ? reasons(catalog, selected, kit.id) : [];
|
|
476
|
-
const detail = focused?.action
|
|
477
|
-
? focused.description
|
|
478
|
-
: browsingProviders
|
|
479
|
-
? [
|
|
480
|
-
focused?.selectedCount ? `${focused.selectedCount} selected` : '',
|
|
481
|
-
focused?.downloadedCount
|
|
482
|
-
? `${focused.downloadedCount} downloaded`
|
|
483
|
-
: '',
|
|
484
|
-
]
|
|
485
|
-
.filter(Boolean)
|
|
486
|
-
.join(' · ') || 'Choose a provider to explore its kits'
|
|
487
|
-
: kit?.ready === false
|
|
488
|
-
? 'Edit this kit, then set ready: true in kit.yaml'
|
|
489
|
-
: kit && willUninstall(kit.id)
|
|
490
|
-
? 'Uninstall on apply. Select again to keep.'
|
|
491
|
-
: kit && hasUpdate(kit)
|
|
492
|
-
? `Catalog update · ${kit.pinned.ref.slice(0, 8)} → ${kit.external.ref.slice(0, 8)}`
|
|
493
|
-
: why.length
|
|
494
|
-
? `Required by ${why.map(displayName).join(', ')}${selected.includes(kit.id) ? ' · also selected' : ' · space to keep explicitly'}`
|
|
495
|
-
: kit?.requires.length
|
|
496
|
-
? `Requires ${kit.requires.map(displayName).join(', ')}`
|
|
497
|
-
: kit?.external
|
|
498
|
-
? `Includes ${(kit.pinned ?? kit.external).skills.map((p) => p.split('/').at(-1)).join(', ')}`
|
|
499
|
-
: '';
|
|
500
488
|
const pagination = listEntries.length > page.length
|
|
501
489
|
? `${start + 1}–${start + page.length} of ${listEntries.length}`
|
|
502
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('─')}`;
|
|
503
493
|
return [
|
|
504
494
|
...beforeList,
|
|
505
495
|
...lines,
|
|
496
|
+
` ${muted(fit(detail, width - 2))}`,
|
|
506
497
|
bottomRule,
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
+
}),
|
|
510
514
|
...help,
|
|
511
515
|
'\u001b[?25l',
|
|
512
516
|
].join('\n');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Do not commit changes or perform Git operations. Leave Git to the user.
|
|
@@ -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 →
|
|
57
|
+
Drop `--dry-run --diff` to apply, or select via `loadout` → Browse → Personal → Review changes.
|