Skip to content

Commit 5155d70

Browse files
committed
feat: add release workflow with git-cliff changelog + fix TUI improvements
- Add cliff.toml with user-oriented French changelog categories - Add .github/workflows/release.yml (tag push v* → git-cliff → GitHub Release) - Add lock.test.mjs to CI test-cli job - Fix test counts in both READMEs (870 tests: 559 JS + 311 Python) - Add Releases & Changelog section to both READMEs - TUI: improve input handling, renderer, state management
1 parent 9782eab commit 5155d70

10 files changed

Lines changed: 389 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
node-version: ${{ matrix.node-version }}
5353

5454
- name: Run CLI tests
55-
run: node --test tests/cli.test.mjs tests/tui.test.mjs
55+
run: node --test tests/cli.test.mjs tests/tui.test.mjs tests/lock.test.mjs
5656

5757
lint:
5858
runs-on: ubuntu-latest

.github/workflows/release.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
permissions:
8+
contents: write
9+
10+
concurrency:
11+
group: release-${{ github.ref }}
12+
cancel-in-progress: false
13+
14+
jobs:
15+
release:
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 10
18+
defaults:
19+
run:
20+
shell: bash
21+
steps:
22+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
23+
with:
24+
fetch-depth: 0
25+
26+
- name: Generate changelog
27+
id: changelog
28+
uses: orhun/git-cliff-action@4a7e37a97063bd7a2f8faa8a2756ea3fe4c9bdbb # v4.4.2
29+
with:
30+
config: cliff.toml
31+
args: --latest --strip header
32+
33+
- name: Create GitHub Release
34+
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.3.2
35+
with:
36+
body: ${{ steps.changelog.outputs.content }}
37+
prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }}
38+
generate_release_notes: ${{ steps.changelog.outputs.content == '' }}

README.en.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
[![CI](https://github.com/dmicheneau/opencode-template-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/dmicheneau/opencode-template-agent/actions/workflows/ci.yml)
66
![Agents](https://img.shields.io/badge/agents-70-blue)
7-
![Tests](https://img.shields.io/badge/tests-427%20passing-brightgreen)
7+
![Tests](https://img.shields.io/badge/tests-870%20passing-brightgreen)
88
![License](https://img.shields.io/badge/license-MIT-blue)
99
![Node](https://img.shields.io/badge/node-20%2B-green)
1010
![npm](https://img.shields.io/npm/v/opencode-agents?label=npm&color=cb3837)
@@ -259,13 +259,48 @@ gh workflow run "Sync Agents" -f tier=all -f force=true # Full forced syn
259259
| `scripts/update-manifest.py` | Merges sync manifest with the main manifest |
260260
| `scripts/sync_common.py` | Shared HTTP utilities and helpers |
261261

262+
## 🚀 Releases & Changelog
263+
264+
The changelog is automatically generated from Git history using [git-cliff](https://git-cliff.org), user-oriented with clear categories.
265+
266+
### How it works
267+
268+
1. **Tag push** — push a `v*` tag (e.g., `git tag v8.0.0 && git push --tags`)
269+
2. **Changelog generation** — git-cliff analyzes commits since the last tag and generates a structured changelog
270+
3. **GitHub Release** — a release is automatically created with the changelog as the body
271+
272+
### Changelog categories
273+
274+
| Commit prefix | Changelog category |
275+
|---------------|-------------------|
276+
| `feat` | ✨ New features |
277+
| `fix` | 🐛 Bug fixes |
278+
| `perf` | ⚡ Performance |
279+
| `docs` | 📝 Documentation |
280+
| `refactor` | ♻️ Refactoring |
281+
| `chore`, `ci`, `build`, `style`, `test` | 🔧 Maintenance |
282+
283+
> Commits with `BREAKING CHANGE` are prefixed with **BREAKING:** in their respective category.
284+
285+
### Creating a release
286+
287+
```bash
288+
# Bump version in package.json, tag and push
289+
npm version major # or minor, patch
290+
git push --follow-tags
291+
292+
# Or manually
293+
git tag v8.0.0
294+
git push --tags
295+
```
296+
262297
## 🧪 Tests
263298

264-
**427 tests** (250 JS + 177 Python).
299+
**870 tests** (559 JS + 311 Python).
265300

266301
```bash
267302
# All JS tests (CLI + TUI)
268-
node --test tests/cli.test.mjs tests/tui.test.mjs
303+
node --test tests/cli.test.mjs tests/tui.test.mjs tests/lock.test.mjs
269304

270305
# All Python tests
271306
python3 tests/run_tests.py

README.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
[![CI](https://github.com/dmicheneau/opencode-template-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/dmicheneau/opencode-template-agent/actions/workflows/ci.yml)
66
![Agents](https://img.shields.io/badge/agents-70-blue)
7-
![Tests](https://img.shields.io/badge/tests-427%20passing-brightgreen)
7+
![Tests](https://img.shields.io/badge/tests-870%20passing-brightgreen)
88
![License](https://img.shields.io/badge/license-MIT-blue)
99
![Node](https://img.shields.io/badge/node-20%2B-green)
1010
![npm](https://img.shields.io/npm/v/opencode-agents?label=npm&color=cb3837)
@@ -274,13 +274,50 @@ gh workflow run "Sync Agents" -f tier=all -f force=true # Sync complète
274274

275275
---
276276

277+
## 🚀 Releases & Changelog
278+
279+
Le changelog est généré automatiquement à partir de l'historique Git via [git-cliff](https://git-cliff.org), orienté utilisateur avec des catégories claires.
280+
281+
### Fonctionnement
282+
283+
1. **Tag push** — pousser un tag `v*` (ex: `git tag v8.0.0 && git push --tags`)
284+
2. **Génération du changelog** — git-cliff analyse les commits depuis le dernier tag et génère un changelog structuré
285+
3. **GitHub Release** — une release est créée automatiquement avec le changelog comme corps
286+
287+
### Catégories du changelog
288+
289+
| Préfixe commit | Catégorie changelog |
290+
|----------------|---------------------|
291+
| `feat` | ✨ Nouveautés |
292+
| `fix` | 🐛 Corrections |
293+
| `perf` | ⚡ Performance |
294+
| `docs` | 📝 Documentation |
295+
| `refactor` | ♻️ Refactoring |
296+
| `chore`, `ci`, `build`, `style`, `test` | 🔧 Maintenance |
297+
298+
> Les commits avec `BREAKING CHANGE` sont préfixés **BREAKING:** dans leur catégorie respective.
299+
300+
### Créer une release
301+
302+
```bash
303+
# Bumper la version dans package.json, tagger et pousser
304+
npm version major # ou minor, patch
305+
git push --follow-tags
306+
307+
# Ou manuellement
308+
git tag v8.0.0
309+
git push --tags
310+
```
311+
312+
---
313+
277314
## 🧪 Tests
278315

279-
**427 tests** (250 JS + 177 Python).
316+
**870 tests** (559 JS + 311 Python).
280317

281318
```bash
282319
# Tous les tests JS (CLI + TUI)
283-
node --test tests/cli.test.mjs tests/tui.test.mjs
320+
node --test tests/cli.test.mjs tests/tui.test.mjs tests/lock.test.mjs
284321

285322
# Tous les tests Python
286323
python3 tests/run_tests.py

cliff.toml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
[changelog]
2+
header = ""
3+
trim = true
4+
body = """
5+
{% if version %}\
6+
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
7+
{% else %}\
8+
## [Unreleased]
9+
{% endif %}\
10+
11+
{% for group, commits in commits | group_by(attribute="group") %}\
12+
### {{ group }}
13+
{% for commit in commits %}\
14+
- {% if commit.breaking %}**BREAKING:** {% endif %}\
15+
{% if commit.scope %}({{ commit.scope }}) {% endif %}\
16+
{{ commit.message | split(pat=": ") | last }}\
17+
([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\
18+
{% endfor %}
19+
{% endfor %}
20+
"""
21+
footer = """
22+
{%- for release in releases -%}
23+
{% if release.previous %}\
24+
**Full Changelog**: [{{ release.previous.version }}...{{ release.version }}]({{ self::remote_url() }}/compare/{{ release.previous.version }}...{{ release.version }})
25+
{% endif %}\
26+
{%- endfor -%}
27+
"""
28+
29+
[git]
30+
conventional_commits = true
31+
filter_unconventional = true
32+
split_commits = false
33+
commit_parsers = [
34+
{ message = "^feat", group = "✨ Nouveautés" },
35+
{ message = "^fix", group = "🐛 Corrections" },
36+
{ message = "^perf", group = "⚡ Performance" },
37+
{ message = "^docs", group = "📝 Documentation" },
38+
{ message = "^refactor", group = "♻️ Refactoring" },
39+
{ message = "^chore", group = "🔧 Maintenance" },
40+
{ message = "^ci", group = "🔧 Maintenance" },
41+
{ message = "^build", group = "🔧 Maintenance" },
42+
{ message = "^style", group = "🔧 Maintenance" },
43+
{ message = "^test", group = "🔧 Maintenance" },
44+
{ message = "^.*", skip = true },
45+
]
46+
protect_breaking_commits = true
47+
filter_commits = true
48+
tag_pattern = "v[0-9].*"
49+
sort_commits = "oldest"
50+
skip_tags = ""
51+
52+
[remote.github]
53+
owner = "dmicheneau"
54+
repo = "opencode-template-agent"

src/tui/index.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseKey } from './input.mjs';
88
import { createInitialState, update, getViewportHeight, detectInstalled, enterPresetSelect } from './state.mjs';
99
import { render } from './renderer.mjs';
1010
import { SPINNER_INTERVAL_MS } from './ansi.mjs';
11+
import { detectAgentStates } from '../lock.mjs';
1112

1213
/**
1314
* Launch the interactive TUI.
@@ -29,6 +30,7 @@ export async function launchTUI(options = {}) {
2930

3031
// ─── Initialize ───────────────────────────────────────────────────────
3132
let state = createInitialState(manifest, getSize());
33+
state = { ...state, agentStates: detectAgentStates(manifest) };
3234
enter();
3335

3436
// ─── C3: Console hijacking state at launchTUI scope ───────────────────
@@ -102,9 +104,10 @@ export async function launchTUI(options = {}) {
102104

103105
// Refresh installed set after installation
104106
const installed = detectInstalled(state.manifest);
107+
const agentStates = detectAgentStates(state.manifest);
105108

106109
// Done
107-
state = { ...state, mode: 'done', installed, install: { ...state.install, done: true } };
110+
state = { ...state, mode: 'done', installed, agentStates, install: { ...state.install, done: true } };
108111
redraw();
109112
} catch (err) {
110113
state = {
@@ -174,6 +177,7 @@ export async function launchTUI(options = {}) {
174177

175178
// Refresh installed set
176179
const installed = detectInstalled(state.manifest);
180+
const agentStates = detectAgentStates(state.manifest);
177181

178182
const flashMsg = result === 'removed'
179183
? `✓ Agent "${target.name}" removed`
@@ -185,6 +189,7 @@ export async function launchTUI(options = {}) {
185189
...state,
186190
mode: 'browse',
187191
installed,
192+
agentStates,
188193
uninstallTarget: null,
189194
flash: { message: flashMsg, ts: Date.now() },
190195
};

src/tui/input.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export const Action = Object.freeze({
3838
// Done mode
3939
FORCE: 'FORCE',
4040

41+
// Batch install
42+
INSTALL_ALL: 'INSTALL_ALL',
43+
4144
// Uninstall
4245
UNINSTALL: 'UNINSTALL',
4346

@@ -95,6 +98,7 @@ const R_YES = Object.freeze({ action: Action.YES });
9598
const R_NO = Object.freeze({ action: Action.NO });
9699
const R_FORCE = Object.freeze({ action: Action.FORCE });
97100
const R_UNINSTALL = Object.freeze({ action: Action.UNINSTALL });
101+
const R_INSTALL_ALL = Object.freeze({ action: Action.INSTALL_ALL });
98102
const R_PERM_APPLY = Object.freeze({ action: Action.PERM_APPLY_ALL });
99103
const R_BASH_ADD = Object.freeze({ action: Action.BASH_ADD });
100104
const R_BASH_DEL = Object.freeze({ action: Action.BASH_DELETE });
@@ -229,6 +233,7 @@ export function parseKey(data, mode) {
229233
if (raw === '/') return R_SEARCH;
230234
if (raw === 'q' || raw === 'Q') return R_QUIT;
231235
if (raw === 'a' || raw === 'A') return R_SELECT_ALL;
236+
if (raw === 'i' || raw === 'I') return R_INSTALL_ALL;
232237
if (raw === 'x' || raw === 'X') return R_UNINSTALL;
233238

234239
return R_NONE;

src/tui/renderer.mjs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,16 @@ function bdr(content, W, bg) {
4545

4646
function topBorder(W, state) {
4747
const title = ' OPENCODE AGENTS ';
48+
const instCount = state.installed?.size || 0;
49+
const totalCount = state.allAgents?.length || 0;
50+
const counterTxt = `✔ ${instCount}/${totalCount} `;
4851
const sel = state.selection.size;
4952
const selTxt = sel > 0 ? `─ ${sel} selected ` : '';
5053
const prefix = cyan(BOX.topLeft + BOX.horizontal) + bold(brightCyan(title)) + cyan(BOX.horizontal);
54+
const counter = stateInstalled(counterTxt);
5155
const suffix = selTxt ? bold(brightGreen(selTxt)) : '';
52-
const fill = cyan(BOX.horizontal.repeat(Math.max(0, W - visibleLength(prefix) - visibleLength(suffix) - 1)));
53-
return CLEAR_LINE + prefix + fill + suffix + cyan(BOX.topRight);
56+
const fill = cyan(BOX.horizontal.repeat(Math.max(0, W - visibleLength(prefix) - visibleLength(counter) - visibleLength(suffix) - 1)));
57+
return CLEAR_LINE + prefix + counter + fill + suffix + cyan(BOX.topRight);
5458
}
5559

5660
function botBorder(W) {
@@ -222,11 +226,13 @@ function renderInfo(state, out, W, total, vh, off) {
222226
// ─── Status Bar ─────────────────────────────────────────────────────────────
223227

224228
function renderStatus(state, out, W) {
229+
const isPacksTab = state.tabs.ids[state.tabs.activeIndex] === 'packs';
230+
const installAllHint = !isPacksTab ? ` ${cyan('[i]')} ${white('Install All')}` : '';
225231
const bar = state.search?.active
226232
? ` ${cyan('[Enter]')} ${white('Apply')} ${cyan('[Esc]')} ${white('Cancel')}`
227233
: state.search?.query
228-
? ` ${white('Filter:')} ${cyan('"' + state.search.query + '"')} ${cyan('[/]')} ${white('Search')} ${cyan('[Space]')} ${white('Select')} ${cyan('[Enter]')} ${white('Install')} ${cyan('[x]')} ${white('Uninstall')} ${cyan('[Tab]')} ${white('Next')} ${cyan('[q]')} ${white('Quit')}`
229-
: ` ${cyan('[/]')} ${white('Search')} ${cyan('[Space]')} ${white('Select')} ${cyan('[Enter]')} ${white('Install')} ${cyan('[x]')} ${white('Uninstall')} ${cyan('[Tab]')} ${white('Next tab')} ${cyan('[q]')} ${white('Quit')}`;
234+
? ` ${white('Filter:')} ${cyan('"' + state.search.query + '"')} ${cyan('[/]')} ${white('Search')} ${cyan('[Space]')} ${white('Select')} ${cyan('[Enter]')} ${white('Install')}${installAllHint} ${cyan('[x]')} ${white('Uninstall')} ${cyan('[Tab]')} ${white('Next')} ${cyan('[q]')} ${white('Quit')}`
235+
: ` ${cyan('[/]')} ${white('Search')} ${cyan('[Space]')} ${white('Select')} ${cyan('[Enter]')} ${white('Install')}${installAllHint} ${cyan('[x]')} ${white('Uninstall')} ${cyan('[Tab]')} ${white('Next tab')} ${cyan('[q]')} ${white('Quit')}`;
230236
out.push(bdr(bar, W));
231237
}
232238

src/tui/state.mjs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function createInitialState(manifest, terminal) {
118118
terminal,
119119
manifest,
120120
allAgents,
121-
// TODO: populated by detectAgentStates() in orchestrator
121+
// Populated by detectAgentStates() in orchestrator (index.mjs)
122122
agentStates: new Map(),
123123
};
124124
}
@@ -221,6 +221,25 @@ function updateBrowse(state, { action }) {
221221
uninstallTarget: { agent: item, name: item.name },
222222
};
223223
}
224+
case Action.INSTALL_ALL: {
225+
// Skip on packs tab — items are packs, not agents
226+
const isPacksTab = state.tabs.ids[state.tabs.activeIndex] === 'packs';
227+
if (isPacksTab) return state;
228+
// Filter to uninstalled agents in the current view
229+
const uninstalled = state.list.items.filter(a => a.name && !state.installed?.has(a.name));
230+
if (uninstalled.length === 0) {
231+
return { ...state, flash: { message: 'All agents in this view are already installed', ts: Date.now() } };
232+
}
233+
const sel = new Set(uninstalled.map(a => a.name));
234+
const agents = state.allAgents.filter(a => sel.has(a.name));
235+
return {
236+
...state,
237+
mode: 'confirm',
238+
selection: sel,
239+
install: { agents, progress: 0, current: 0, results: [], error: null, doneCursor: 0, doneScrollOffset: 0, forceSelection: new Set() },
240+
confirmContext: { type: 'agents', count: agents.length },
241+
};
242+
}
224243
case Action.QUIT:
225244
case Action.ESCAPE: return { ...state, mode: 'quit' };
226245
default: return state;
@@ -356,6 +375,7 @@ function updatePackDetail(state, { action }) {
356375
}
357376
case Action.ESCAPE:
358377
return { ...state, mode: 'browse', packDetail: null, flash: null, confirmContext: null };
378+
case Action.INSTALL_ALL: return state; // [i] disabled in pack detail view
359379
default: return state;
360380
}
361381
}

0 commit comments

Comments
 (0)