@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.
- package/framework/skills/README.md +1 -0
- package/framework/skills/grilling/SKILL.md +34 -0
- package/framework/skills/grilling/examples.md +47 -0
- package/package.json +1 -1
- package/src/agent-configs.js +78 -10
- package/src/catalog.js +13 -0
- package/src/cli.js +353 -179
- package/src/doctor.js +15 -19
- package/src/installer.js +25 -44
- package/src/item-layout.js +26 -0
|
@@ -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
package/src/agent-configs.js
CHANGED
|
@@ -74,21 +74,89 @@ export function generateOpenCodeConfig({ selections, mcpEntries, includeAgentsMd
|
|
|
74
74
|
return JSON.stringify(config, null, 4);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export
|
|
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:
|
|
81
|
-
scroll_speed:
|
|
144
|
+
theme: prefs.theme,
|
|
145
|
+
scroll_speed: prefs.scroll_speed,
|
|
82
146
|
scroll_acceleration: {
|
|
83
|
-
enabled:
|
|
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
|
-
|
|
86
|
-
mouse: true,
|
|
154
|
+
mouse: prefs.mouse,
|
|
87
155
|
attention: {
|
|
88
|
-
enabled:
|
|
89
|
-
notifications:
|
|
90
|
-
sound:
|
|
91
|
-
volume:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
116
|
-
for (const [cat, data] of
|
|
117
|
-
|
|
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
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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(
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
347
|
+
const visibleItems = catConfig.items.filter(item => !item.removed);
|
|
348
|
+
const existingIds = initialItemValues(visibleItems, existingSelections[cat] || []);
|
|
231
349
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
+
if (ui.isCancel(confirmed) || !confirmed) {
|
|
422
|
+
ui.outro('Installation cancelled.');
|
|
423
|
+
return { status: 'cancelled' };
|
|
308
424
|
}
|
|
309
425
|
|
|
310
|
-
const
|
|
311
|
-
|
|
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 ?
|
|
434
|
+
oldSelections: oldLock ? existingSelections : undefined,
|
|
319
435
|
oldLock,
|
|
320
|
-
|
|
321
|
-
|
|
436
|
+
tuiPreferences,
|
|
437
|
+
force,
|
|
438
|
+
dryRun,
|
|
322
439
|
});
|
|
323
440
|
|
|
324
|
-
|
|
441
|
+
progress.stop('Installation complete!');
|
|
325
442
|
|
|
326
|
-
const fileCount = Object.values(selections).reduce((sum,
|
|
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
|
-
|
|
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
|
|
51
|
+
for (const entry of Object.values(entries)) {
|
|
47
52
|
for (const [path, expected] of Object.entries(entry.files || {})) {
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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: ${
|
|
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
|
|
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
|
|
346
|
-
const
|
|
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(
|
|
350
|
-
await writeManagedFile(destFile, content,
|
|
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: ${
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 (!
|
|
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
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
}
|