@mohammadhprp/system-prompt 0.13.0 → 0.13.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.
@@ -21,6 +21,7 @@ This catalog is framework-agnostic: each skill defines when to activate, a step-
21
21
  | [effective-html](./effective-html/SKILL.md) | Create self-contained HTML artifacts with routed guidance for design, wireframes, prototypes, plans, and diagrams. | Standalone HTML reports, explainers, interfaces, wireframes, prototypes, plans, and diagrams. |
22
22
  | [find-skills](./find-skills/SKILL.md) | Discover, evaluate, and install skills from the open agent skills ecosystem. | Finding an installable skill for a specialized task or extending an agent's capabilities. |
23
23
  | [frontend-design](./frontend-design/SKILL.md) | Distinctive, intentional visual design for new UI or reshaping existing UI — aesthetic direction, typography, and choices that don't read as templated defaults. | Building new interfaces, reshaping existing UI, or escaping generic AI-generated design looks. |
24
+ | [grilling](./grilling/SKILL.md) | Grill plans and decisions relentlessly through design-tree rounds until shared understanding is reached. | Stress-testing ideas, surfacing hidden assumptions, and scoping plans before implementation. |
24
25
  | [glab](./glab/SKILL.md) | Use the GitLab CLI (glab) to manage merge requests, issues, pipelines, and repositories from the command line. | Any project hosted on GitLab (SaaS or self-hosted). |
25
26
  | [humanizer](./humanizer/SKILL.md) | Remove signs of AI-generated writing from text — inflated importance, promotional language, em dash overuse, rule of three, AI vocabulary, and filler phrases. | Editing or reviewing prose to make it sound more natural and human-written. |
26
27
  | [improve](./improve/SKILL.md) | Audit repositories as a read-only senior advisor and produce prioritized implementation plans for another agent. | Repository-wide audits, improvement roadmaps, and implementation handoffs. |
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: grilling
3
+ description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
4
+ ---
5
+
6
+ # grilling
7
+
8
+ Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
9
+
10
+ Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled: the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
11
+
12
+ Format a round like so:
13
+
14
+ ```
15
+ ❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
16
+
17
+ ➡️ <your recommended answer>
18
+
19
+ ---
20
+
21
+ ❓ **Q2** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
22
+
23
+ ➡️ <your recommended answer>
24
+ ```
25
+
26
+ Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
27
+
28
+ Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
29
+
30
+ The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
31
+
32
+ ## Source
33
+
34
+ Adapted from [skills](https://github.com/mattpocock/skills/blob/main/skills/productivity/grilling/SKILL.md) by Matt Pocock, MIT License.
@@ -0,0 +1,47 @@
1
+ # Grilling Skill — Example Outputs
2
+
3
+ Realistic examples of how the `grilling` skill stress-tests thinking through design-tree rounds.
4
+
5
+ ## Example 1: New feature plan (frontier round, recommendations, wait)
6
+
7
+ **User:** "Grill me on my plan to add Redis caching to the API."
8
+
9
+ **What the agent does:**
10
+
11
+ Maps the design tree (invalidation strategy branches off what is cached; TTL branches off invalidation; cache key shape branches off endpoints covered), computes the frontier, and asks round 1:
12
+
13
+ ```
14
+ ❓ **Q1** - **What gets cached**: Which endpoints or queries go through Redis — everything, read-heavy endpoints only, or specific hot queries?
15
+
16
+ ➡️ Read-heavy endpoints only. Caching everything multiplies invalidation bugs for little gain.
17
+
18
+ ---
19
+
20
+ ❓ **Q2** - **Where Redis lives**: Managed service (Upstash/ElastiCache), self-hosted container, or reuse existing infrastructure?
21
+
22
+ ➡️ Managed service. One less stateful thing to operate for a first caching pass.
23
+ ```
24
+
25
+ Then waits. Q3 (TTL values) stays out of this round — it depends on Q1's answer.
26
+
27
+ **Result:** Whole frontier asked at once, each with a recommendation, no guessing at unsettled answers.
28
+
29
+ ## Example 2: Facts via sub-agent, decisions via user (don't block)
30
+
31
+ **User:** Answers Q1 with "specific hot queries" and Q2 with "self-hosted container."
32
+
33
+ **What the agent does:**
34
+
35
+ Settled decisions push the frontier outward. Q3 (TTL per query) is now askable. But "which queries are actually hot" is a fact, not a decision — dispatches a sub-agent to check access logs and slow-query stats instead of asking the user. Asks the rest of the frontier now (eviction policy, invalidation trigger); only the TTL question waits for the sub-agent's report.
36
+
37
+ **Result:** Facts looked up, decisions put to the user, no blocking on exploration.
38
+
39
+ ## Example 3: Session close (frontier empty, confirm before acting)
40
+
41
+ **User:** Answers the final round on invalidation triggers.
42
+
43
+ **What the agent does:**
44
+
45
+ Frontier is empty — every branch visited (scope, hosting, TTLs, eviction, invalidation). States the shared understanding back in one summary and asks for confirmation. Does not write any code or config until the user confirms.
46
+
47
+ **Result:** Nothing silently assumed, no action before confirmed understanding.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohammadhprp/system-prompt",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "AI Coding Agent Framework — interactive bootstrap CLI",
5
5
  "keywords": [
6
6
  "ai",
@@ -74,21 +74,89 @@ export function generateOpenCodeConfig({ selections, mcpEntries, includeAgentsMd
74
74
  return JSON.stringify(config, null, 4);
75
75
  }
76
76
 
77
- export function generateTuiConfig({ selections }) {
77
+ export const TUI_THEMES = [
78
+ 'system',
79
+ 'tokyonight',
80
+ 'everforest',
81
+ 'ayu',
82
+ 'catppuccin',
83
+ 'catppuccin-macchiato',
84
+ 'gruvbox',
85
+ 'kanagawa',
86
+ 'nord',
87
+ 'matrix',
88
+ 'one-dark',
89
+ ];
90
+
91
+ export const TUI_DEFAULTS = {
92
+ theme: 'system',
93
+ scroll_speed: 3,
94
+ scroll_acceleration: true,
95
+ diff_style: 'auto',
96
+ mouse: true,
97
+ cursor: { style: 'block', blinking: true },
98
+ attention: { enabled: true, notifications: true, sound: true, volume: 0.4 },
99
+ };
100
+
101
+ export function normalizeTuiPreferences(preferences = {}) {
102
+ const source = preferences || {};
103
+ const cursor = source.cursor || {};
104
+ const attention = source.attention || {};
105
+ const scrollSpeed = Number(source.scroll_speed);
106
+ const volume = Number(attention.volume);
107
+
108
+ return {
109
+ theme: typeof source.theme === 'string' && source.theme ? source.theme : TUI_DEFAULTS.theme,
110
+ scroll_speed: Number.isFinite(scrollSpeed) && scrollSpeed >= 0.001 ? scrollSpeed : TUI_DEFAULTS.scroll_speed,
111
+ scroll_acceleration: typeof source.scroll_acceleration === 'boolean' ? source.scroll_acceleration : TUI_DEFAULTS.scroll_acceleration,
112
+ diff_style: source.diff_style === 'stacked' ? 'stacked' : 'auto',
113
+ mouse: typeof source.mouse === 'boolean' ? source.mouse : TUI_DEFAULTS.mouse,
114
+ cursor: {
115
+ style: ['block', 'underline', 'line', 'default'].includes(cursor.style) ? cursor.style : TUI_DEFAULTS.cursor.style,
116
+ blinking: typeof cursor.blinking === 'boolean' ? cursor.blinking : TUI_DEFAULTS.cursor.blinking,
117
+ },
118
+ attention: {
119
+ enabled: typeof attention.enabled === 'boolean' ? attention.enabled : TUI_DEFAULTS.attention.enabled,
120
+ notifications: typeof attention.notifications === 'boolean' ? attention.notifications : TUI_DEFAULTS.attention.notifications,
121
+ sound: typeof attention.sound === 'boolean' ? attention.sound : TUI_DEFAULTS.attention.sound,
122
+ volume: Number.isFinite(volume) && volume >= 0 && volume <= 1 ? volume : TUI_DEFAULTS.attention.volume,
123
+ },
124
+ };
125
+ }
126
+
127
+ export function tuiPreferencesFromConfig(config) {
128
+ if (!config || typeof config !== 'object') return {};
129
+ const preferences = {};
130
+ if (typeof config.theme === 'string') preferences.theme = config.theme;
131
+ if (typeof config.scroll_speed === 'number') preferences.scroll_speed = config.scroll_speed;
132
+ if (typeof config.scroll_acceleration?.enabled === 'boolean') preferences.scroll_acceleration = config.scroll_acceleration.enabled;
133
+ if (typeof config.diff_style === 'string') preferences.diff_style = config.diff_style;
134
+ if (typeof config.mouse === 'boolean') preferences.mouse = config.mouse;
135
+ if (config.cursor && typeof config.cursor === 'object') preferences.cursor = config.cursor;
136
+ if (config.attention && typeof config.attention === 'object') preferences.attention = config.attention;
137
+ return preferences;
138
+ }
139
+
140
+ export function generateTuiConfig({ selections, preferences }) {
141
+ const prefs = normalizeTuiPreferences(preferences);
78
142
  const config = {
79
143
  $schema: 'https://opencode.ai/tui.json',
80
- theme: 'system',
81
- scroll_speed: 3,
144
+ theme: prefs.theme,
145
+ scroll_speed: prefs.scroll_speed,
82
146
  scroll_acceleration: {
83
- enabled: true,
147
+ enabled: prefs.scroll_acceleration,
148
+ },
149
+ diff_style: prefs.diff_style,
150
+ cursor: {
151
+ style: prefs.cursor.style,
152
+ blinking: prefs.cursor.blinking,
84
153
  },
85
- diff_style: 'auto',
86
- mouse: true,
154
+ mouse: prefs.mouse,
87
155
  attention: {
88
- enabled: true,
89
- notifications: true,
90
- sound: true,
91
- volume: 0.4,
156
+ enabled: prefs.attention.enabled,
157
+ notifications: prefs.attention.notifications,
158
+ sound: prefs.attention.sound,
159
+ volume: prefs.attention.volume,
92
160
  },
93
161
  };
94
162
 
package/src/catalog.js CHANGED
@@ -3,6 +3,7 @@ export const categories = {
3
3
  title: 'Skills',
4
4
  description: 'Task-specific procedures for AI coding agents',
5
5
  sourceDir: 'framework/skills',
6
+ itemLayout: 'directory',
6
7
  items: [
7
8
  { id: 'adhd', name: 'ADHD', description: 'Shape output for ADHD readers: action first, numbered steps, restated state, no tangents' },
8
9
  { id: 'agent-browser', name: 'Agent Browser', description: 'Automate browser and Electron workflows for navigation, testing, screenshots, and data extraction' },
@@ -17,6 +18,7 @@ export const categories = {
17
18
  { id: 'effective-html', name: 'Effective HTML', description: 'Create self-contained HTML artifacts with routed guidance for design, wireframes, prototypes, plans, and diagrams' },
18
19
  { id: 'find-skills', name: 'Find Skills', description: 'Discover, evaluate, and install agent skills for specialized tasks' },
19
20
  { id: 'frontend-design', name: 'Frontend Design', description: 'Distinctive, intentional visual design for new UI or reshaping existing UI' },
21
+ { id: 'grilling', name: 'Grilling', description: 'Grill plans and decisions relentlessly through design-tree rounds until shared understanding is reached' },
20
22
  { id: 'gh', name: 'GitHub CLI', description: 'Work with GitHub via the gh CLI for repositories, issues, pull requests, Actions, releases, and APIs' },
21
23
  { id: 'glab', name: 'Glab', description: 'Work with GitLab via the glab CLI for MRs, issues, and pipelines' },
22
24
  { id: 'humanizer', name: 'Humanizer', description: 'Remove signs of AI-generated writing to make text sound more natural and human' },
@@ -46,6 +48,7 @@ export const categories = {
46
48
  title: 'Subagents',
47
49
  description: 'Specialized subagents for security, architecture, review, and research',
48
50
  sourceDir: 'framework/agents',
51
+ itemLayout: 'file',
49
52
  items: [
50
53
  { id: 'researcher', name: 'Researcher', description: 'Fetch and analyze web content from URLs' },
51
54
  { id: 'reviewer', name: 'Reviewer', description: 'Review code for correctness and best practices' },
@@ -57,6 +60,7 @@ export const categories = {
57
60
  title: 'Slash Commands',
58
61
  description: 'Slash command workflows for repeatable tasks',
59
62
  sourceDir: 'framework/commands',
63
+ itemLayout: 'file',
60
64
  items: [
61
65
  { id: 'audit-your-codebase', name: 'Audit Your Codebase', description: 'Audit for materially useful simplifications in structure, state, algorithms, and ownership' },
62
66
  { id: 'explain-codebase', name: 'Explain Codebase', description: 'Map a codebase and teach it interactively, from overview to focused deep-dives' },
@@ -69,6 +73,8 @@ export const categories = {
69
73
  title: 'MCPs',
70
74
  description: 'Model Context Protocol servers for AI coding agents',
71
75
  sourceDir: 'framework/mcps',
76
+ itemLayout: 'directory',
77
+ copyItems: false,
72
78
  items: [
73
79
  { id: 'excalidraw', name: 'Excalidraw MCP', description: 'Stream hand-drawn diagrams with interactive editing' },
74
80
  { id: 'figma-mcp-go', name: 'Figma MCP Go', description: 'Read/write access to Figma designs via plugin bridge' },
@@ -82,6 +88,8 @@ export const categories = {
82
88
  title: 'Plugins',
83
89
  description: 'OpenCode plugins that extend the core agent',
84
90
  sourceDir: 'framework/plugins',
91
+ itemLayout: 'directory',
92
+ copyItems: false,
85
93
  items: [
86
94
  { id: 'opencode-goal-plugin', name: 'OpenCode Goal Plugin', description: 'Goal-driven long-running tasks with persistence' },
87
95
  { id: 'ponytail', name: 'Ponytail', description: 'Lazy senior dev mode — YAGNI-first, reuse-first ladder that reduces code bloat and cost' },
@@ -92,6 +100,7 @@ export const categories = {
92
100
  title: 'Styles',
93
101
  description: 'Design system references from Refero Styles',
94
102
  sourceDir: 'framework/styles',
103
+ itemLayout: 'directory',
95
104
  items: [
96
105
  { id: 'factory', name: 'Factory', description: 'Terminal war room design system' },
97
106
  { id: 'huly', name: 'Huly', description: 'Midnight observatory design system' },
@@ -105,6 +114,7 @@ export const categories = {
105
114
  title: 'Modes',
106
115
  description: 'Behavior, tool, and prompt presets for different use cases',
107
116
  sourceDir: 'framework/modes',
117
+ itemLayout: 'file',
108
118
  items: [
109
119
  { id: 'audit', name: 'Audit', description: 'Read-only high-scrutiny review mode for evaluating artifacts' },
110
120
  ],
@@ -114,6 +124,7 @@ export const categories = {
114
124
  title: 'Memory',
115
125
  description: 'Persistent agent memory files for cross-session context',
116
126
  sourceDir: 'framework/memory',
127
+ itemLayout: 'file',
117
128
  items: [
118
129
  { id: 'codebase-insights', name: 'Codebase Insights', description: 'Non-obvious facts, gotchas, past decisions, and architecture quirks' },
119
130
  { id: 'user-preferences', name: 'User Preferences', description: 'Coding style, naming conventions, and architectural preferences' },
@@ -124,6 +135,7 @@ export const categories = {
124
135
  title: 'Standards',
125
136
  description: 'Canonical engineering standards',
126
137
  sourceDir: 'framework/references/standards',
138
+ itemLayout: 'file',
127
139
  items: [
128
140
  { id: 'api', name: 'API Design', description: 'REST API design standards' },
129
141
  { id: 'architecture', name: 'Architecture', description: 'System architecture standards' },
@@ -144,6 +156,7 @@ export const categories = {
144
156
  title: 'Templates',
145
157
  description: 'Fillable workflow documents',
146
158
  sourceDir: 'framework/references/templates',
159
+ itemLayout: 'file',
147
160
  items: [
148
161
  { id: 'adr', name: 'ADR', description: 'Architecture Decision Record' },
149
162
  { id: 'api-spec', name: 'API Spec', description: 'API specification document' },
package/src/cli.js CHANGED
@@ -1,12 +1,25 @@
1
- import { intro, outro, confirm, multiselect, spinner, isCancel } from '@clack/prompts';
1
+ import { intro, outro, confirm, multiselect, spinner, select, isCancel } from '@clack/prompts';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { resolve } from 'node:path';
3
4
 
4
5
  import { categories } from './catalog.js';
5
6
  import { getPackageVersion, install, loadLockFile, lockToSelections } from './installer.js';
6
7
  import { doctor } from './doctor.js';
8
+ import { normalizeTuiPreferences, tuiPreferencesFromConfig, TUI_THEMES } from './agent-configs.js';
7
9
 
8
10
  const CATEGORY_FLAGS = new Set(Object.keys(categories));
9
11
 
12
+ const defaultUi = {
13
+ intro,
14
+ outro,
15
+ confirm,
16
+ multiselect,
17
+ spinner,
18
+ select,
19
+ isCancel,
20
+ log: (...args) => console.log(...args),
21
+ };
22
+
10
23
  export function parseArgs(argv) {
11
24
  const options = {
12
25
  targetDir: '.opencode',
@@ -48,29 +61,28 @@ export function parseArgs(argv) {
48
61
  return options;
49
62
  }
50
63
 
51
- function allSelections() {
64
+ export function allSelections() {
52
65
  return Object.fromEntries(Object.entries(categories).map(([category, config]) => [
53
66
  category,
54
67
  config.items.filter(item => !item.removed).map(item => item.id),
55
68
  ]));
56
69
  }
57
70
 
71
+ function itemNames(config, ids) {
72
+ return ids.map(id => config?.items?.find(item => item.id === id)?.name || id);
73
+ }
74
+
58
75
  function buildSummary(selections) {
59
76
  const lines = [];
60
77
  for (const [cat, ids] of Object.entries(selections)) {
61
78
  if (!ids?.length) continue;
62
79
  const catConfig = categories[cat];
63
- const label = catConfig?.title || cat;
64
- const names = ids.map(id => {
65
- const item = catConfig?.items.find(i => i.id === id);
66
- return item ? item.name : id;
67
- });
68
- lines.push(` ${label}: ${names.join(', ')}`);
80
+ lines.push(` ${catConfig?.title || cat}: ${itemNames(catConfig, ids).join(', ')}`);
69
81
  }
70
82
  return lines.join('\n');
71
83
  }
72
84
 
73
- function computeDiff(oldLock, selections) {
85
+ export function computeDiff(oldLock, selections) {
74
86
  const oldSels = lockToSelections(oldLock);
75
87
  const added = {};
76
88
  const removed = {};
@@ -95,240 +107,402 @@ function computeDiff(oldLock, selections) {
95
107
  return { added, removed, kept };
96
108
  }
97
109
 
98
- function formatDiff(diff) {
99
- const { added, removed, kept } = diff;
110
+ export function formatDiff(diff) {
111
+ const sections = [
112
+ ['+ Added', diff.added],
113
+ ['- Removed', diff.removed],
114
+ ['~ Unchanged', diff.kept],
115
+ ];
100
116
  const lines = [];
101
117
 
102
- if (Object.keys(added).length) {
103
- lines.push(' + Added:');
104
- for (const [cat, data] of Object.entries(added)) {
105
- const names = data.ids.map(id => {
106
- const item = data.config?.items?.find(i => i.id === id);
107
- return item?.name || id;
108
- });
109
- lines.push(` ${data.config?.title || cat}: ${names.join(', ')}`);
110
- }
111
- }
112
-
113
- if (Object.keys(removed).length) {
118
+ for (const [label, group] of sections) {
119
+ const entries = Object.entries(group);
120
+ if (!entries.length) continue;
114
121
  if (lines.length) lines.push('');
115
- lines.push(' - Removed:');
116
- for (const [cat, data] of Object.entries(removed)) {
117
- const names = data.ids.map(id => {
118
- const item = data.config?.items?.find(i => i.id === id);
119
- return item?.name || id;
120
- });
121
- lines.push(` ${data.config?.title || cat}: ${names.join(', ')}`);
122
- }
123
- }
124
-
125
- if (Object.keys(kept).length) {
126
- if (lines.length) lines.push('');
127
- lines.push(' ~ Unchanged:');
128
- for (const [cat, data] of Object.entries(kept)) {
129
- const names = data.ids.map(id => {
130
- const item = data.config?.items?.find(i => i.id === id);
131
- return item?.name || id;
132
- });
133
- lines.push(` ${data.config?.title || cat}: ${names.join(', ')}`);
122
+ lines.push(` ${label}:`);
123
+ for (const [cat, data] of entries) {
124
+ lines.push(` ${data.config?.title || cat}: ${itemNames(data.config, data.ids).join(', ')}`);
134
125
  }
135
126
  }
136
127
 
137
128
  return lines.join('\n');
138
129
  }
139
130
 
140
- export async function main(argv = process.argv.slice(2)) {
141
- const args = parseArgs(argv);
142
- if (args.doctor) {
143
- const healthy = await doctor(args.targetDir);
144
- if (!healthy) process.exitCode = 1;
145
- return healthy;
131
+ export function initialItemValues(visibleItems, existingIds = []) {
132
+ const visible = new Set(visibleItems.map(item => item.id));
133
+ return existingIds.filter(id => visible.has(id));
134
+ }
135
+
136
+ export function shouldPreselectAll(visibleItems, existingIds = []) {
137
+ if (existingIds.length) return existingIds.length === visibleItems.length;
138
+ return visibleItems.length <= 12;
139
+ }
140
+
141
+ async function readTuiPreferences(absTarget) {
142
+ try {
143
+ return tuiPreferencesFromConfig(JSON.parse(await readFile(resolve(absTarget, 'tui.json'), 'utf-8')));
144
+ } catch (error) {
145
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
146
+ throw error;
146
147
  }
148
+ }
147
149
 
148
- const version = await getPackageVersion();
149
- intro(`System prompt (v${version})`);
150
+ async function collectTuiPreferences(existing, ui) {
151
+ const base = normalizeTuiPreferences(existing);
152
+
153
+ const theme = await ui.select({
154
+ message: 'TUI theme',
155
+ options: TUI_THEMES.map(value => ({
156
+ value,
157
+ label: value,
158
+ hint: value === 'system' ? 'Adapts to your terminal' : undefined,
159
+ })),
160
+ initialValue: base.theme,
161
+ });
162
+ if (ui.isCancel(theme)) return null;
163
+
164
+ const diffStyle = await ui.select({
165
+ message: 'Diff style',
166
+ options: [
167
+ { value: 'auto', label: 'auto', hint: 'Adapts to terminal width' },
168
+ { value: 'stacked', label: 'stacked', hint: 'Always single column' },
169
+ ],
170
+ initialValue: base.diff_style,
171
+ });
172
+ if (ui.isCancel(diffStyle)) return null;
150
173
 
151
- const agentType = 'opencode';
152
- const targetDir = args.targetDir;
174
+ const cursorStyle = await ui.select({
175
+ message: 'Cursor style',
176
+ options: ['block', 'underline', 'line', 'default'].map(value => ({ value, label: value })),
177
+ initialValue: base.cursor.style,
178
+ });
179
+ if (ui.isCancel(cursorStyle)) return null;
180
+
181
+ const scrollSpeed = await ui.select({
182
+ message: 'Scroll speed',
183
+ options: [
184
+ { value: 1, label: '1', hint: 'Slow' },
185
+ { value: 2, label: '2' },
186
+ { value: 3, label: '3', hint: 'Default' },
187
+ { value: 4, label: '4' },
188
+ { value: 5, label: '5', hint: 'Fast' },
189
+ ],
190
+ initialValue: base.scroll_speed,
191
+ });
192
+ if (ui.isCancel(scrollSpeed)) return null;
153
193
 
154
- if (args.nonInteractive) {
155
- const selections = args.all ? allSelections() : args.selections;
156
- const absTarget = resolve(process.cwd(), targetDir);
157
- const oldLock = await loadLockFile(absTarget);
158
- if (!args.dryRun) console.log(`Installing selected components into ${absTarget}`);
159
- await install({
160
- targetDir,
161
- agentType,
162
- selections,
163
- includeAgentsMd: args.includeAgentsMd,
164
- oldSelections: oldLock ? lockToSelections(oldLock) : undefined,
165
- oldLock,
166
- force: args.force,
167
- dryRun: args.dryRun,
194
+ const scrollAcceleration = await ui.confirm({
195
+ message: 'Enable scroll acceleration?',
196
+ initialValue: base.scroll_acceleration,
197
+ });
198
+ if (ui.isCancel(scrollAcceleration)) return null;
199
+
200
+ const mouse = await ui.confirm({
201
+ message: 'Enable mouse support?',
202
+ initialValue: base.mouse,
203
+ });
204
+ if (ui.isCancel(mouse)) return null;
205
+
206
+ const attentionEnabled = await ui.confirm({
207
+ message: 'Enable attention notifications and sounds?',
208
+ initialValue: base.attention.enabled,
209
+ });
210
+ if (ui.isCancel(attentionEnabled)) return null;
211
+
212
+ const attention = { ...base.attention, enabled: attentionEnabled };
213
+ if (attentionEnabled) {
214
+ const notifications = await ui.confirm({
215
+ message: 'Desktop notifications?',
216
+ initialValue: base.attention.notifications,
168
217
  });
169
- console.log(args.dryRun ? 'Dry run complete.' : 'Installation complete.');
170
- return;
218
+ if (ui.isCancel(notifications)) return null;
219
+
220
+ const sound = await ui.confirm({
221
+ message: 'Sound alerts?',
222
+ initialValue: base.attention.sound,
223
+ });
224
+ if (ui.isCancel(sound)) return null;
225
+
226
+ const volume = await ui.select({
227
+ message: 'Alert volume',
228
+ options: [
229
+ { value: 0.2, label: '20%' },
230
+ { value: 0.4, label: '40%', hint: 'Default' },
231
+ { value: 0.6, label: '60%' },
232
+ { value: 0.8, label: '80%' },
233
+ { value: 1, label: '100%' },
234
+ ],
235
+ initialValue: base.attention.volume,
236
+ });
237
+ if (ui.isCancel(volume)) return null;
238
+
239
+ attention.notifications = notifications;
240
+ attention.sound = sound;
241
+ attention.volume = volume;
171
242
  }
172
243
 
173
- const categoryOptions = Object.entries(categories).map(([key, cat]) => {
174
- const visible = cat.items.filter(i => !i.removed);
244
+ return normalizeTuiPreferences({
245
+ theme,
246
+ diff_style: diffStyle,
247
+ cursor: { style: cursorStyle, blinking: base.cursor.blinking },
248
+ scroll_speed: scrollSpeed,
249
+ scroll_acceleration: scrollAcceleration,
250
+ mouse,
251
+ attention,
252
+ });
253
+ }
254
+
255
+ function cancelled(ui) {
256
+ ui.outro('Cancelled.');
257
+ return { status: 'cancelled' };
258
+ }
259
+
260
+ export function buildCategoryOptions() {
261
+ return Object.entries(categories).map(([key, cat]) => {
262
+ const visible = cat.items.filter(item => !item.removed);
175
263
  return {
176
264
  value: key,
177
265
  label: cat.title,
178
266
  hint: `${visible.length} ${key === 'mcps' ? 'MCPs' : cat.title.toLowerCase()}`,
179
267
  };
180
268
  });
269
+ }
181
270
 
182
- const selectedCategories = await multiselect({
183
- message: 'What would you like to install?',
184
- options: categoryOptions,
185
- required: false,
271
+ async function installGeneratedFiles({ ui, targetDir, agentType, force, dryRun, oldLock, existingSelections, existingTuiPreferences }) {
272
+ const includeAgentsMd = await ui.confirm({
273
+ message: 'Generate AGENTS.md?',
274
+ initialValue: oldLock ? oldLock.includeAgentsMd : true,
186
275
  });
187
- if (isCancel(selectedCategories)) {
188
- outro('Cancelled.');
189
- process.exit(0);
276
+ if (ui.isCancel(includeAgentsMd)) return cancelled(ui);
277
+
278
+ const hasExisting = Object.keys(existingSelections).length > 0;
279
+ let keepExisting = false;
280
+ if (hasExisting) {
281
+ const removeAll = await ui.confirm({
282
+ message: 'Remove all previously installed components?',
283
+ initialValue: false,
284
+ });
285
+ if (ui.isCancel(removeAll)) return cancelled(ui);
286
+ keepExisting = !removeAll;
190
287
  }
191
288
 
192
- if (!selectedCategories?.length) {
193
- const includeAgentsMd = await confirm({
194
- message: 'Generate AGENTS.md?',
195
- initialValue: true,
196
- });
197
- if (isCancel(includeAgentsMd)) {
198
- outro('Cancelled.');
199
- process.exit(0);
200
- }
289
+ const progress = ui.spinner();
290
+ progress.start('Writing files...');
291
+ await install({
292
+ targetDir,
293
+ agentType,
294
+ selections: keepExisting ? existingSelections : {},
295
+ includeAgentsMd,
296
+ oldSelections: hasExisting ? existingSelections : undefined,
297
+ oldLock,
298
+ tuiPreferences: existingTuiPreferences,
299
+ force,
300
+ dryRun,
301
+ });
302
+ progress.stop('Done.');
201
303
 
202
- const s = spinner();
203
- s.start('Writing files...');
204
- await install({
205
- targetDir,
206
- agentType,
207
- selections: {},
208
- includeAgentsMd,
209
- });
210
- s.stop('Done.');
211
- const installed = [];
212
- if (includeAgentsMd) installed.push('AGENTS.md');
213
- outro(`${installed.join(' and ')} written. Open them in your project to get started.`);
214
- process.exit(0);
215
- }
304
+ const installed = includeAgentsMd ? ['AGENTS.md'] : [];
305
+ ui.outro(`${installed.join(' and ')} written. Open them in your project to get started.`);
306
+ return { status: 'installed' };
307
+ }
216
308
 
217
- const selections = {};
309
+ function reportPlan({ ui, oldLock, selections, includeAgentsMd }) {
310
+ if (oldLock) {
311
+ const diffText = formatDiff(computeDiff(oldLock, selections));
312
+ ui.log('\n📦 Changes from previous installation:\n');
313
+ if (diffText) {
314
+ ui.log(diffText);
315
+ ui.log();
316
+ } else {
317
+ ui.log(' No changes — same selections as before.\n');
318
+ }
218
319
 
219
- const includeAgentsMd = await confirm({
220
- message: 'Generate AGENTS.md?',
221
- initialValue: true,
222
- });
223
- if (isCancel(includeAgentsMd)) {
224
- outro('Cancelled.');
225
- process.exit(0);
320
+ const generatedFiles = [];
321
+ if (includeAgentsMd) generatedFiles.push('AGENTS.md');
322
+ generatedFiles.push('opencode.json', 'tui.json', '.gitignore');
323
+ if (selections.mcps?.length) generatedFiles.push('.env');
324
+ if (selections.memory?.length) generatedFiles.push('memory/');
325
+
326
+ if (generatedFiles.length) {
327
+ ui.log(' Generated files:');
328
+ for (const file of generatedFiles) ui.log(` 📄 ${file}`);
329
+ ui.log();
330
+ }
331
+ return;
226
332
  }
227
333
 
334
+ ui.log('\n📦 Summary of what will be installed:\n');
335
+ if (includeAgentsMd) ui.log(' 📄 AGENTS.md');
336
+ ui.log(' 📄 opencode.json');
337
+ ui.log(' 📄 tui.json');
338
+ ui.log(' 📄 .gitignore');
339
+ ui.log(buildSummary(selections));
340
+ ui.log();
341
+ }
342
+
343
+ async function collectSelections({ ui, selectedCategories, existingSelections }) {
344
+ const selections = {};
228
345
  for (const cat of selectedCategories) {
229
346
  const catConfig = categories[cat];
230
- const allIds = catConfig.items.map(i => i.id);
347
+ const visibleItems = catConfig.items.filter(item => !item.removed);
348
+ const existingIds = initialItemValues(visibleItems, existingSelections[cat] || []);
231
349
 
232
- const visibleItems = catConfig.items.filter(i => !i.removed);
233
-
234
- const all = await confirm({
235
- message: `Install all ${catConfig.title.toLowerCase()}?`,
236
- initialValue: visibleItems.length <= 12,
237
- });
238
- if (isCancel(all)) {
239
- outro('Cancelled.');
240
- process.exit(0);
241
- }
350
+ const all = await ui.confirm({
351
+ message: `Install all ${catConfig.title.toLowerCase()}?`,
352
+ initialValue: shouldPreselectAll(visibleItems, existingIds),
353
+ });
354
+ if (ui.isCancel(all)) return null;
242
355
 
243
- if (all) {
244
- selections[cat] = visibleItems.map(i => i.id);
245
- } else {
246
- const picked = await multiselect({
247
- message: `Which ${catConfig.title.toLowerCase()} do you want?`,
248
- options: visibleItems.map(item => ({
249
- value: item.id,
250
- label: item.deprecated ? `${item.name} (deprecated)` : item.name,
251
- hint: item.deprecated ? '⚠ Deprecated — consider alternatives' : item.description,
252
- })),
253
- required: true,
254
- });
255
- if (isCancel(picked)) {
256
- outro('Cancelled.');
257
- process.exit(0);
258
- }
259
- selections[cat] = picked;
356
+ if (all) {
357
+ selections[cat] = visibleItems.map(item => item.id);
358
+ continue;
260
359
  }
261
- }
262
360
 
263
- const absTarget = resolve(process.cwd(), targetDir);
264
- const oldLock = await loadLockFile(absTarget);
361
+ const picked = await ui.multiselect({
362
+ message: `Which ${catConfig.title.toLowerCase()} do you want?`,
363
+ options: visibleItems.map(item => ({
364
+ value: item.id,
365
+ label: item.deprecated ? `${item.name} (deprecated)` : item.name,
366
+ hint: item.deprecated ? '⚠ Deprecated — consider alternatives' : item.description,
367
+ })),
368
+ initialValues: existingIds,
369
+ required: true,
370
+ });
371
+ if (ui.isCancel(picked)) return null;
372
+ selections[cat] = picked;
373
+ }
374
+ return selections;
375
+ }
265
376
 
266
- if (oldLock) {
267
- const diff = computeDiff(oldLock, selections);
268
- const diffText = formatDiff(diff);
377
+ async function installSelectedItems({ ui, targetDir, agentType, force, dryRun, oldLock, existingSelections, existingTuiPreferences }, selectedCategories) {
378
+ const includeAgentsMd = await ui.confirm({
379
+ message: 'Generate AGENTS.md?',
380
+ initialValue: oldLock ? oldLock.includeAgentsMd : true,
381
+ });
382
+ if (ui.isCancel(includeAgentsMd)) return cancelled(ui);
269
383
 
270
- console.log('\n📦 Changes from previous installation:\n');
271
- if (diffText) {
272
- console.log(diffText);
273
- console.log();
274
- } else {
275
- console.log(' No changes — same selections as before.\n');
276
- }
384
+ const selections = await collectSelections({ ui, selectedCategories, existingSelections });
385
+ if (!selections) return cancelled(ui);
277
386
 
278
- const genFiles = [];
279
- if (includeAgentsMd) genFiles.push('AGENTS.md');
280
- genFiles.push('opencode.json', 'tui.json', '.gitignore');
281
- if (selections.mcps?.length) genFiles.push('.env');
282
- if (selections.memory?.length) genFiles.push('memory/');
387
+ let tuiPreferences = existingTuiPreferences;
388
+ const customizeTui = await ui.confirm({
389
+ message: 'Customize OpenCode TUI settings?',
390
+ initialValue: false,
391
+ });
392
+ if (ui.isCancel(customizeTui)) return cancelled(ui);
393
+ if (customizeTui) {
394
+ const custom = await collectTuiPreferences(existingTuiPreferences, ui);
395
+ if (!custom) return cancelled(ui);
396
+ tuiPreferences = custom;
397
+ }
283
398
 
284
- if (genFiles.length) {
285
- console.log(' Generated files:');
286
- for (const f of genFiles) {
287
- console.log(` 📄 ${f}`);
399
+ if (oldLock) {
400
+ const initialDiff = computeDiff(oldLock, selections);
401
+ if (Object.keys(initialDiff.removed).length) {
402
+ const removeDeselected = await ui.confirm({
403
+ message: 'Remove deselected items from the previous installation?',
404
+ initialValue: false,
405
+ });
406
+ if (ui.isCancel(removeDeselected)) return cancelled(ui);
407
+ if (!removeDeselected) {
408
+ for (const [cat, data] of Object.entries(initialDiff.removed)) {
409
+ selections[cat] = [...new Set([...(selections[cat] || []), ...data.ids])];
410
+ }
288
411
  }
289
- console.log();
290
412
  }
291
- } else {
292
- console.log('\n📦 Summary of what will be installed:\n');
293
- if (includeAgentsMd) console.log(' 📄 AGENTS.md');
294
- console.log(' 📄 opencode.json');
295
- console.log(' 📄 tui.json');
296
- console.log(' 📄 .gitignore');
297
- console.log(buildSummary(selections));
298
- console.log();
299
413
  }
300
414
 
301
- const confirmed = await confirm({
415
+ reportPlan({ ui, oldLock, selections, includeAgentsMd });
416
+
417
+ const confirmed = await ui.confirm({
302
418
  message: 'Proceed with installation?',
303
419
  initialValue: true,
304
420
  });
305
- if (isCancel(confirmed) || !confirmed) {
306
- outro('Installation cancelled.');
307
- process.exit(0);
421
+ if (ui.isCancel(confirmed) || !confirmed) {
422
+ ui.outro('Installation cancelled.');
423
+ return { status: 'cancelled' };
308
424
  }
309
425
 
310
- const s = spinner();
311
- s.start(oldLock ? 'Updating files...' : 'Installing files...');
426
+ const progress = ui.spinner();
427
+ progress.start(oldLock ? 'Updating files...' : 'Installing files...');
312
428
 
313
429
  const finalTarget = await install({
314
430
  targetDir,
315
431
  agentType,
316
432
  selections,
317
433
  includeAgentsMd,
318
- oldSelections: oldLock ? lockToSelections(oldLock) : undefined,
434
+ oldSelections: oldLock ? existingSelections : undefined,
319
435
  oldLock,
320
- force: args.force,
321
- dryRun: args.dryRun,
436
+ tuiPreferences,
437
+ force,
438
+ dryRun,
322
439
  });
323
440
 
324
- s.stop('Installation complete!');
441
+ progress.stop('Installation complete!');
325
442
 
326
- const fileCount = Object.values(selections).reduce((sum, arr) => sum + (arr?.length || 0), 0);
443
+ const fileCount = Object.values(selections).reduce((sum, ids) => sum + (ids?.length || 0), 0);
327
444
  const verb = oldLock ? 'Updated' : 'Installed';
328
- outro(`${verb} ${fileCount} components to ${finalTarget}
445
+ ui.outro(`${verb} ${fileCount} components to ${finalTarget}
329
446
 
330
447
  Next steps:
331
448
  ${agentType === 'opencode' ? '- Open your project in OpenCode — it will read opencode.json and AGENTS.md automatically' : '- Point your AI coding agent to AGENTS.md as the entry point'}
332
449
  - Run /help in your agent to see available commands
333
450
  `);
451
+ return { status: 'installed', target: finalTarget };
452
+ }
453
+
454
+ export async function runInteractive({ targetDir, force = false, dryRun = false, agentType = 'opencode', ui = defaultUi }) {
455
+ const absTarget = resolve(process.cwd(), targetDir);
456
+ const oldLock = await loadLockFile(absTarget);
457
+ const existingSelections = lockToSelections(oldLock);
458
+ const existingTuiPreferences = await readTuiPreferences(absTarget);
459
+ const context = { ui, targetDir, agentType, force, dryRun, oldLock, existingSelections, existingTuiPreferences };
460
+
461
+ const selectedCategories = await ui.multiselect({
462
+ message: 'What would you like to install?',
463
+ options: buildCategoryOptions(),
464
+ initialValues: Object.keys(existingSelections),
465
+ required: false,
466
+ });
467
+ if (ui.isCancel(selectedCategories)) return cancelled(ui);
468
+
469
+ if (!selectedCategories?.length) return installGeneratedFiles(context);
470
+ return installSelectedItems(context, selectedCategories);
471
+ }
472
+
473
+ export async function runNonInteractive({ targetDir, selections, all, includeAgentsMd, force = false, dryRun = false, agentType = 'opencode', ui = defaultUi }) {
474
+ const resolvedSelections = all ? allSelections() : selections;
475
+ const absTarget = resolve(process.cwd(), targetDir);
476
+ const oldLock = await loadLockFile(absTarget);
477
+ if (!dryRun) ui.log(`Installing selected components into ${absTarget}`);
478
+ await install({
479
+ targetDir,
480
+ agentType,
481
+ selections: resolvedSelections,
482
+ includeAgentsMd,
483
+ oldSelections: oldLock ? lockToSelections(oldLock) : undefined,
484
+ oldLock,
485
+ force,
486
+ dryRun,
487
+ });
488
+ ui.log(dryRun ? 'Dry run complete.' : 'Installation complete.');
489
+ }
490
+
491
+ export async function main(argv = process.argv.slice(2)) {
492
+ const args = parseArgs(argv);
493
+ if (args.doctor) {
494
+ const healthy = await doctor(args.targetDir);
495
+ if (!healthy) process.exitCode = 1;
496
+ return healthy;
497
+ }
498
+
499
+ const version = await getPackageVersion();
500
+ defaultUi.intro(`System prompt (v${version})`);
501
+
502
+ const agentType = 'opencode';
503
+
504
+ if (args.nonInteractive) {
505
+ return runNonInteractive({ ...args, agentType });
506
+ }
507
+ return runInteractive({ ...args, agentType });
334
508
  }
package/src/doctor.js CHANGED
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
4
4
 
5
5
  import { categories } from './catalog.js';
6
6
  import { loadLockFile, lockToSelections } from './installer.js';
7
+ import { itemRelativePath } from './item-layout.js';
7
8
 
8
9
  async function exists(path) {
9
10
  try {
@@ -14,6 +15,16 @@ async function exists(path) {
14
15
  }
15
16
  }
16
17
 
18
+ async function checkManagedFile(absTarget, relativePath, expectedHash, issues) {
19
+ try {
20
+ const actual = createHash('sha256').update(await readFile(resolve(absTarget, relativePath))).digest('hex');
21
+ if (actual !== expectedHash) issues.push(`Modified managed file: ${relativePath}`);
22
+ } catch (error) {
23
+ if (error.code === 'ENOENT') issues.push(`Missing managed file: ${relativePath}`);
24
+ else throw error;
25
+ }
26
+ }
27
+
17
28
  export async function inspectInstallation(targetDir) {
18
29
  const absTarget = resolve(process.cwd(), targetDir);
19
30
  const issues = [];
@@ -32,26 +43,14 @@ export async function inspectInstallation(targetDir) {
32
43
  }
33
44
 
34
45
  for (const [path, entry] of Object.entries(lock.generated || {})) {
35
- try {
36
- const actual = createHash('sha256').update(await readFile(resolve(absTarget, path))).digest('hex');
37
- if (actual !== entry.computedHash) issues.push(`Modified managed file: ${path}`);
38
- } catch (error) {
39
- if (error.code === 'ENOENT') issues.push(`Missing managed file: ${path}`);
40
- else throw error;
41
- }
46
+ await checkManagedFile(absTarget, path, entry.computedHash, issues);
42
47
  }
43
48
 
44
49
  for (const [category, entries] of Object.entries(lock)) {
45
50
  if (!categories[category]) continue;
46
- for (const [id, entry] of Object.entries(entries)) {
51
+ for (const entry of Object.values(entries)) {
47
52
  for (const [path, expected] of Object.entries(entry.files || {})) {
48
- try {
49
- const actual = createHash('sha256').update(await readFile(resolve(absTarget, path))).digest('hex');
50
- if (actual !== expected) issues.push(`Modified managed file: ${path}`);
51
- } catch (error) {
52
- if (error.code === 'ENOENT') issues.push(`Missing managed file: ${path}`);
53
- else throw error;
54
- }
53
+ await checkManagedFile(absTarget, path, expected, issues);
55
54
  }
56
55
  }
57
56
  }
@@ -60,10 +59,7 @@ export async function inspectInstallation(targetDir) {
60
59
  const config = categories[category];
61
60
  for (const id of ids) {
62
61
  const item = config.items.find(entry => entry.id === id);
63
- const relativePath = ['agents', 'commands', 'memory', 'modes', 'standards', 'templates'].includes(category)
64
- ? `${config.sourceDir.replace(/^framework\//, '')}/${id}.md`
65
- : `${config.sourceDir.replace(/^framework\//, '')}/${id}`;
66
- if (!item || !(await exists(resolve(absTarget, relativePath)))) {
62
+ if (!item || !(await exists(resolve(absTarget, itemRelativePath(category, id))))) {
67
63
  issues.push(`Missing installed ${category} item: ${id}`);
68
64
  }
69
65
  }
package/src/installer.js CHANGED
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
5
5
 
6
6
  import { categories } from './catalog.js';
7
7
  import { loadMcpConfigs, generateOpenCodeConfig, generateTuiConfig } from './agent-configs.js';
8
+ import { LOCK_CATEGORIES, isFileBased, isCopyable, itemSourcePath, itemRelativePath, targetSubdir } from './item-layout.js';
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const packageRoot = resolve(__dirname, '..');
@@ -87,9 +88,6 @@ bun.lock
87
88
  `;
88
89
 
89
90
  export const LOCK_VERSION = 1;
90
- export const LOCK_CATEGORIES = ['skills', 'agents', 'commands', 'mcps', 'plugins', 'styles', 'modes', 'memory', 'standards', 'templates'];
91
-
92
- const FILE_BASED = new Set(['agents', 'commands', 'memory', 'modes', 'standards', 'templates']);
93
91
 
94
92
  export function lockToSelections(lock) {
95
93
  const selections = {};
@@ -101,12 +99,6 @@ export function lockToSelections(lock) {
101
99
  return selections;
102
100
  }
103
101
 
104
- function itemSourcePath(category, id) {
105
- const config = categories[category];
106
- if (FILE_BASED.has(category)) return `${config.sourceDir}/${id}.md`;
107
- return `${config.sourceDir}/${id}`;
108
- }
109
-
110
102
  function buildComputedHash(files, fallbackId) {
111
103
  const keys = Object.keys(files).sort();
112
104
  if (keys.length === 0) return hash(fallbackId);
@@ -308,22 +300,22 @@ async function copySelectedDirs(targetDir, category, selectedIds, options) {
308
300
  const catConfig = categories[category];
309
301
  if (!catConfig || !selectedIds?.length) return;
310
302
 
311
- const relativeDir = targetSubdir(catConfig.sourceDir);
312
- const destParent = resolve(targetDir, relativeDir);
303
+ const destParent = resolve(targetDir, targetSubdir(catConfig.sourceDir));
313
304
 
314
305
  for (const id of selectedIds) {
315
306
  if (await isRemoved(category, id)) continue;
316
307
 
317
- const srcPath = resolveSource(`${catConfig.sourceDir}/${id}`);
308
+ const source = itemSourcePath(category, id);
309
+ const srcPath = resolveSource(source);
318
310
  const destPath = resolve(destParent, id);
319
311
  await assertSafeDestination(targetDir, destPath);
320
312
 
321
313
  try {
322
314
  await stat(srcPath);
323
- await copyDir(srcPath, destPath, `${relativeDir}/${id}`, options, { category, id });
315
+ await copyDir(srcPath, destPath, itemRelativePath(category, id), options, { category, id });
324
316
  } catch (error) {
325
317
  if (sourceMissing(error)) {
326
- console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}`);
318
+ console.warn(` ⚠ Source not found: ${source}`);
327
319
  continue;
328
320
  }
329
321
  throw error;
@@ -335,22 +327,22 @@ async function copySelectedFiles(targetDir, category, selectedIds, options) {
335
327
  const catConfig = categories[category];
336
328
  if (!catConfig || !selectedIds?.length) return;
337
329
 
338
- const relativeDir = targetSubdir(catConfig.sourceDir);
339
- const destParent = resolve(targetDir, relativeDir);
330
+ const destParent = resolve(targetDir, targetSubdir(catConfig.sourceDir));
340
331
  if (!options.dryRun) await mkdir(destParent, { recursive: true });
341
332
 
342
333
  for (const id of selectedIds) {
343
334
  if (await isRemoved(category, id)) continue;
344
335
 
345
- const srcFile = resolveSource(`${catConfig.sourceDir}/${id}.md`);
346
- const destFile = resolve(destParent, `${id}.md`);
336
+ const source = itemSourcePath(category, id);
337
+ const relativePath = itemRelativePath(category, id);
338
+ const destFile = resolve(targetDir, relativePath);
347
339
  await assertSafeDestination(targetDir, destFile);
348
340
  try {
349
- const content = await readFile(srcFile);
350
- await writeManagedFile(destFile, content, `${relativeDir}/${id}.md`, options, { category, id });
341
+ const content = await readFile(resolveSource(source));
342
+ await writeManagedFile(destFile, content, relativePath, options, { category, id });
351
343
  } catch (error) {
352
344
  if (sourceMissing(error)) {
353
- console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}.md`);
345
+ console.warn(` ⚠ Source not found: ${source}`);
354
346
  continue;
355
347
  }
356
348
  throw error;
@@ -359,21 +351,14 @@ async function copySelectedFiles(targetDir, category, selectedIds, options) {
359
351
  }
360
352
 
361
353
  async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dryRun) {
362
- const catConfig = categories[category];
363
- if (!catConfig || !ids?.length) return;
364
-
365
- if (!FILE_BASED.has(category) && category !== 'skills' && category !== 'styles') return;
354
+ if (!categories[category] || !ids?.length || !isCopyable(category)) return;
366
355
 
367
- const relativeDir = targetSubdir(catConfig.sourceDir);
368
356
  for (const id of ids) {
369
- const relativePath = FILE_BASED.has(category)
370
- ? `${relativeDir}/${id}.md`
371
- : `${relativeDir}/${id}`;
357
+ const relativePath = itemRelativePath(category, id);
372
358
  const destPath = resolve(absTarget, relativePath);
373
359
  await assertSafeDestination(absTarget, destPath);
374
360
  if (!force && oldLock) {
375
- const oldFiles = oldLock?.[category]?.[id]?.files || {};
376
- const managedEntries = Object.entries(oldFiles);
361
+ const managedEntries = Object.entries(oldLock?.[category]?.[id]?.files || {});
377
362
  if (managedEntries.length === 0) {
378
363
  console.warn(` ⚠ Preserving unmanaged item: ${relativePath}`);
379
364
  continue;
@@ -391,7 +376,7 @@ async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dry
391
376
  console.warn(` ⚠ Preserving modified item: ${relativePath}`);
392
377
  continue;
393
378
  }
394
- if (!FILE_BASED.has(category)) {
379
+ if (!isFileBased(category)) {
395
380
  if (!dryRun) {
396
381
  for (const [path] of managedEntries) await rm(resolve(absTarget, path), { force: true });
397
382
  }
@@ -472,7 +457,7 @@ export function validateSelections(selections) {
472
457
  return selections;
473
458
  }
474
459
 
475
- export async function install({ targetDir, agentType, selections, includeAgentsMd = true, writeAgentsMd, oldSelections, oldLock, force = false, dryRun = false }) {
460
+ export async function install({ targetDir, agentType, selections, includeAgentsMd = true, writeAgentsMd, oldSelections, oldLock, force = false, dryRun = false, tuiPreferences }) {
476
461
  validateSelections(selections);
477
462
  if (oldLock) validateLock(oldLock);
478
463
  writeAgentsMd = writeAgentsMd ?? includeAgentsMd;
@@ -516,11 +501,11 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
516
501
  }
517
502
 
518
503
  const tasks = [];
519
- for (const category of ['skills', 'styles']) {
520
- if (selections[category]?.length) tasks.push(copySelectedDirs(absTarget, category, selections[category], options));
521
- }
522
- for (const category of ['agents', 'commands', 'modes', 'memory', 'standards', 'templates']) {
523
- if (selections[category]?.length) tasks.push(copySelectedFiles(absTarget, category, selections[category], options));
504
+ for (const category of LOCK_CATEGORIES) {
505
+ if (!isCopyable(category) || !selections[category]?.length) continue;
506
+ tasks.push(isFileBased(category)
507
+ ? copySelectedFiles(absTarget, category, selections[category], options)
508
+ : copySelectedDirs(absTarget, category, selections[category], options));
524
509
  }
525
510
  await Promise.all(tasks);
526
511
 
@@ -542,11 +527,11 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
542
527
  mcpEntries: previousMcpEntries,
543
528
  includeAgentsMd: oldLock.includeAgentsMd ?? true,
544
529
  }));
545
- previousTuiConfig = JSON.parse(generateTuiConfig({ selections: oldSelections }));
530
+ previousTuiConfig = JSON.parse(generateTuiConfig({ selections: oldSelections, preferences: tuiPreferences }));
546
531
  }
547
532
  const configJson = generateOpenCodeConfig({ selections, mcpEntries, includeAgentsMd });
548
533
  await mergeJsonFile(resolve(absTarget, 'opencode.json'), JSON.parse(configJson), 'opencode.json', options, previousOpenCodeConfig);
549
- await mergeJsonFile(resolve(absTarget, 'tui.json'), JSON.parse(generateTuiConfig({ selections })), 'tui.json', options, previousTuiConfig);
534
+ await mergeJsonFile(resolve(absTarget, 'tui.json'), JSON.parse(generateTuiConfig({ selections, preferences: tuiPreferences })), 'tui.json', options, previousTuiConfig);
550
535
  await mergeGitignore(resolve(absTarget, '.gitignore'), options);
551
536
  }
552
537
 
@@ -596,10 +581,6 @@ function resolveSource(subpath) {
596
581
  return resolve(packageRoot, subpath);
597
582
  }
598
583
 
599
- function targetSubdir(sourceDir) {
600
- return sourceDir.replace(/^framework\//, '');
601
- }
602
-
603
584
  async function isRemoved(category, id) {
604
585
  const catConfig = categories[category];
605
586
  if (!catConfig) return false;
@@ -0,0 +1,26 @@
1
+ import { categories } from './catalog.js';
2
+
3
+ export const LOCK_CATEGORIES = Object.keys(categories);
4
+
5
+ export function isFileBased(category) {
6
+ return categories[category]?.itemLayout === 'file';
7
+ }
8
+
9
+ export function isCopyable(category) {
10
+ const config = categories[category];
11
+ return Boolean(config?.itemLayout) && config.copyItems !== false;
12
+ }
13
+
14
+ export function targetSubdir(sourceDir) {
15
+ return sourceDir.replace(/^framework\//, '');
16
+ }
17
+
18
+ export function itemSourcePath(category, id) {
19
+ const config = categories[category];
20
+ return isFileBased(category) ? `${config.sourceDir}/${id}.md` : `${config.sourceDir}/${id}`;
21
+ }
22
+
23
+ export function itemRelativePath(category, id) {
24
+ const dir = targetSubdir(categories[category].sourceDir);
25
+ return isFileBased(category) ? `${dir}/${id}.md` : `${dir}/${id}`;
26
+ }