@chamoji/cli 0.0.0-stage → 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chamoji contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import { run } from './run.js';
4
+ process.exitCode = await run(process.argv.slice(2), {
5
+ cwd: process.cwd(),
6
+ stdout: text => process.stdout.write(text),
7
+ stderr: text => process.stderr.write(text),
8
+ });
9
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,38 @@
1
+ import { type CommitClassifier } from '@chamoji/core';
2
+ /** The slice of the TypeSafe client this classifier uses (easy to fake in tests). */
3
+ export type JevClient = {
4
+ systemOne(request: {
5
+ state: {
6
+ commits: Array<{
7
+ subject: string;
8
+ body: string;
9
+ }>;
10
+ };
11
+ questions: Record<string, {
12
+ type: 'choice';
13
+ instructions: string;
14
+ criteria: Record<string, string>;
15
+ }>;
16
+ }): Promise<{
17
+ answers: Record<string, {
18
+ choice: string;
19
+ confidence: number;
20
+ }>;
21
+ }>;
22
+ };
23
+ export type JevClassifierOptions = {
24
+ /** Commits per request; each question carries all the gitmoji options. */
25
+ batchSize?: number;
26
+ /** Requests in flight at once. */
27
+ concurrency?: number;
28
+ /** Picks below this confidence are dropped and the commit stays unsorted. */
29
+ minConfidence?: number;
30
+ /** Receives one-line progress notes: failures and a final summary. */
31
+ report?: (line: string) => void;
32
+ };
33
+ /**
34
+ * Sorts commits with Jev (TypeSafe System One): one `choice` question per
35
+ * commit over the gitmoji list. Only subjects and the start of each body are
36
+ * sent, never diffs or code.
37
+ */
38
+ export declare function createJevClassifier(client: JevClient, options?: JevClassifierOptions): CommitClassifier;
@@ -0,0 +1,118 @@
1
+ import { GITMOJIS, } from '@chamoji/core';
2
+ /** Characters of each subject and body sent. */
3
+ const MAX_CHARS = 300;
4
+ /**
5
+ * Upper bound on batch size. Each question repeats the ~75 gitmoji options
6
+ * (about 1.4k tokens), and a request may hold 64k tokens, state included.
7
+ */
8
+ const MAX_BATCH = 30;
9
+ const NONE = 'none';
10
+ /**
11
+ * Not offered: 💥, because only an author can declare a breaking change, and
12
+ * the codes the default config hides, so a wrong pick can't make a commit
13
+ * vanish from the changelog.
14
+ */
15
+ const NOT_OFFERED = new Set(['boom', 'bookmark', 'twisted_rightwards_arrows']);
16
+ const CRITERIA = Object.fromEntries([
17
+ ...GITMOJIS.filter(gitmoji => !NOT_OFFERED.has(gitmoji.code)).map(gitmoji => [
18
+ gitmoji.code,
19
+ gitmoji.description,
20
+ ]),
21
+ [
22
+ NONE,
23
+ 'None of these fit, or the message is too vague to tell what changed.',
24
+ ],
25
+ ]);
26
+ /**
27
+ * Sorts commits with Jev (TypeSafe System One): one `choice` question per
28
+ * commit over the gitmoji list. Only subjects and the start of each body are
29
+ * sent, never diffs or code.
30
+ */
31
+ export function createJevClassifier(client, options = {}) {
32
+ const { batchSize = 25, concurrency = 4,
33
+ // A starting point; tune it against labelled history.
34
+ minConfidence = 0.6, report, } = options;
35
+ if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > MAX_BATCH) {
36
+ throw new RangeError(`batchSize must be an integer from 1 to ${MAX_BATCH}`);
37
+ }
38
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
39
+ throw new RangeError('concurrency must be a positive integer');
40
+ }
41
+ const classifyBatch = async (batch) => {
42
+ const questions = {};
43
+ for (const index of batch.keys()) {
44
+ questions[`commit_${index}`] = {
45
+ type: 'choice',
46
+ instructions: `Which gitmoji best describes the change made by commit \`commits[${index}]\`, judging from its subject and body? Pick the one a maintainer would use when sorting it into a changelog, or ${NONE} if the message is too vague to tell.`,
47
+ criteria: CRITERIA,
48
+ };
49
+ }
50
+ const { answers } = await client.systemOne({
51
+ state: {
52
+ commits: batch.map(commit => ({
53
+ subject: redact(commit.subject).slice(0, MAX_CHARS),
54
+ body: redact(withoutTrailers(commit.body)).trim().slice(0, MAX_CHARS),
55
+ })),
56
+ },
57
+ questions,
58
+ });
59
+ const picks = [];
60
+ for (const [index, commit] of batch.entries()) {
61
+ const answer = answers[`commit_${index}`];
62
+ if (answer &&
63
+ answer.choice !== NONE &&
64
+ answer.confidence >= minConfidence) {
65
+ picks.push([commit.hash, answer.choice]);
66
+ }
67
+ }
68
+ return picks;
69
+ };
70
+ return async (commits) => {
71
+ const batches = [];
72
+ for (let start = 0; start < commits.length; start += batchSize) {
73
+ batches.push(commits.slice(start, start + batchSize));
74
+ }
75
+ const result = new Map();
76
+ let next = 0;
77
+ let sent = 0;
78
+ let failure;
79
+ // `--ai` only improves the output, so a failed request stops further
80
+ // requests but keeps what earlier ones returned.
81
+ const worker = async () => {
82
+ while (next < batches.length && !failure) {
83
+ const batch = batches[next++];
84
+ try {
85
+ for (const [hash, code] of await classifyBatch(batch)) {
86
+ result.set(hash, code);
87
+ }
88
+ sent += batch.length;
89
+ }
90
+ catch (error) {
91
+ failure ??= error;
92
+ }
93
+ }
94
+ };
95
+ await Promise.all(Array.from({ length: Math.min(concurrency, batches.length) }, worker));
96
+ if (failure) {
97
+ report?.(`Jev request failed (${failure.message}); ${commits.length - sent} commits left unsorted`);
98
+ }
99
+ report?.(`Jev sorted ${result.size} of ${commits.length} commits no rule could place`);
100
+ return result;
101
+ };
102
+ }
103
+ const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
104
+ const TRAILER_LINE = /^[\w-]+:\s/;
105
+ /** Replaces email addresses, which often appear in commit text. */
106
+ function redact(text) {
107
+ return text.replaceAll(EMAIL, '<email>');
108
+ }
109
+ /** Drops the final paragraph when it is all `Key: value` trailer lines. */
110
+ function withoutTrailers(body) {
111
+ const paragraphs = body.trim().split(/\n\s*\n/);
112
+ const last = paragraphs.at(-1)?.split('\n') ?? [];
113
+ if (last.length > 0 && last.every(line => TRAILER_LINE.test(line.trim()))) {
114
+ paragraphs.pop();
115
+ }
116
+ return paragraphs.join('\n\n');
117
+ }
118
+ //# sourceMappingURL=jev-classifier.js.map
package/dist/run.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type Io = {
2
+ cwd: string;
3
+ stdout: (text: string) => void;
4
+ stderr: (text: string) => void;
5
+ /** `YYYY-MM-DD` used for `--release`; defaults to today. */
6
+ today?: string;
7
+ };
8
+ export declare const NO_RELEASE_NEEDED = 3;
9
+ export declare function run(argv: string[], io: Io): Promise<number>;
package/dist/run.js ADDED
@@ -0,0 +1,138 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { buildChangelog, loadConfig, render, } from '@chamoji/core';
4
+ import { TypeSafeClient } from '@typesafe-ai/sdk';
5
+ import meow from 'meow';
6
+ import { createJevClassifier } from './jev-classifier.js';
7
+ const HELP = `
8
+ Usage
9
+ $ chamoji changelog [options] Generate a changelog from git history
10
+ $ chamoji next-version [options] Print the next semver version
11
+ (exits 3 when no release is needed)
12
+
13
+ Options
14
+ --format markdown (default), md, json or html
15
+ --output Write to this file instead of stdout
16
+ --from Leave out this release tag and everything older
17
+ --to Ref to treat as the head of history (default: HEAD)
18
+ --release Name the unreleased section with this version
19
+ --config Config file (default: chamoji.config.json if present)
20
+ --ai Sort commits no rule can place with Jev (TypeSafe).
21
+ Needs TYPESAFE_API_KEY. Sends up to 300 characters of
22
+ each such commit's subject and body, with emails and
23
+ trailer lines removed; never diffs, code or authors.
24
+
25
+ Examples
26
+ $ chamoji changelog --output CHANGELOG.md
27
+ $ version=$(chamoji next-version) && chamoji changelog --release "$version" -o CHANGELOG.md
28
+ $ chamoji changelog --format html --output changelog.html
29
+ `;
30
+ export const NO_RELEASE_NEEDED = 3;
31
+ const FORMATS = {
32
+ markdown: 'markdown',
33
+ md: 'markdown',
34
+ json: 'json',
35
+ html: 'html',
36
+ };
37
+ export async function run(argv, io) {
38
+ const cli = meow(HELP, {
39
+ importMeta: import.meta,
40
+ argv,
41
+ autoHelp: false,
42
+ autoVersion: false,
43
+ flags: {
44
+ format: { type: 'string', default: 'markdown' },
45
+ output: { type: 'string', shortFlag: 'o' },
46
+ from: { type: 'string' },
47
+ to: { type: 'string', default: 'HEAD' },
48
+ release: { type: 'string' },
49
+ config: { type: 'string' },
50
+ ai: { type: 'boolean' },
51
+ help: { type: 'boolean', shortFlag: 'h' },
52
+ version: { type: 'boolean', shortFlag: 'v' },
53
+ },
54
+ });
55
+ const { flags } = cli;
56
+ if (flags.help) {
57
+ io.stdout(`${cli.help}\n`);
58
+ return 0;
59
+ }
60
+ if (flags.version) {
61
+ io.stdout(`${cli.pkg.version ?? 'unknown'}\n`);
62
+ return 0;
63
+ }
64
+ const [command] = cli.input;
65
+ try {
66
+ const classify = flags.ai ? jevClassifier(io) : undefined;
67
+ switch (command) {
68
+ case 'changelog': {
69
+ const format = Object.hasOwn(FORMATS, flags.format)
70
+ ? FORMATS[flags.format]
71
+ : undefined;
72
+ if (!format) {
73
+ throw new Error(`Unknown format "${flags.format}" (use markdown, json or html)`);
74
+ }
75
+ const changelog = await buildChangelog({
76
+ cwd: io.cwd,
77
+ config: await loadConfig(io.cwd, flags.config),
78
+ to: flags.to,
79
+ from: flags.from,
80
+ release: flags.release,
81
+ today: io.today,
82
+ classify,
83
+ });
84
+ const output = render(changelog, format);
85
+ if (flags.output) {
86
+ await writeFile(path.resolve(io.cwd, flags.output), output);
87
+ io.stderr(`Wrote ${flags.output}\n`);
88
+ }
89
+ else {
90
+ io.stdout(output);
91
+ }
92
+ return 0;
93
+ }
94
+ case 'next-version': {
95
+ const { releases } = await buildChangelog({
96
+ cwd: io.cwd,
97
+ config: await loadConfig(io.cwd, flags.config),
98
+ to: flags.to,
99
+ classify,
100
+ classifyReleases: 'unreleased',
101
+ });
102
+ const next = releases.find(release => release.tag === null)?.nextVersion;
103
+ if (!next) {
104
+ // Non-zero and silent on stdout, so `$(chamoji next-version)` in a
105
+ // release script stops instead of producing a release called "none".
106
+ io.stderr('chamoji: no release needed (no commits since the last tag imply a version bump)\n');
107
+ return NO_RELEASE_NEEDED;
108
+ }
109
+ io.stdout(`${next}\n`);
110
+ return 0;
111
+ }
112
+ default: {
113
+ io.stderr(`${command ? `chamoji: unknown command "${command}"\n` : ''}${cli.help}\n`);
114
+ return 2;
115
+ }
116
+ }
117
+ }
118
+ catch (error) {
119
+ io.stderr(`chamoji: ${error.message}\n`);
120
+ return 1;
121
+ }
122
+ }
123
+ function jevClassifier(io) {
124
+ const key = process.env['TYPESAFE_API_KEY']?.trim();
125
+ if (!key) {
126
+ throw new Error('--ai needs a TypeSafe API key: set TYPESAFE_API_KEY (get one at https://typesafe.ai)');
127
+ }
128
+ // Keys copied from a dashboard often end in "…"; headers can't carry it.
129
+ const invalid = /[^\x21-\x7E]/.exec(key);
130
+ if (invalid) {
131
+ throw new Error(invalid[0] === '\u2026'
132
+ ? 'TYPESAFE_API_KEY contains "…", so it looks truncated; copy the full key again'
133
+ : 'TYPESAFE_API_KEY contains a character that is not plain ASCII; copy the full key again');
134
+ }
135
+ const client = new TypeSafeClient();
136
+ return createJevClassifier({ systemOne: async (request) => client.systemOne(request) }, { report: line => io.stderr(`${line}\n`) });
137
+ }
138
+ //# sourceMappingURL=run.js.map
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@chamoji/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Changelogs from gitmoji and Conventional Commits",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "bin": {
11
+ "chamoji": "./dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "!dist/**/*.map"
16
+ ],
17
+ "dependencies": {
18
+ "@typesafe-ai/sdk": "^0.7.0",
19
+ "meow": "^14.1.0",
20
+ "@chamoji/core": "0.2.0"
21
+ },
22
+ "keywords": [
23
+ "changelog",
24
+ "gitmoji",
25
+ "conventional-commits",
26
+ "semver",
27
+ "release",
28
+ "cli"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p ."
35
+ }
6
36
  }
package/readme.md ADDED
@@ -0,0 +1,116 @@
1
+ # chamoji
2
+
3
+ Changelogs from your git history. Chamoji reads [gitmoji](https://gitmoji.dev), [Conventional Commits](https://www.conventionalcommits.org) and plain messages like "Fix crash on startup". It groups them into Keep a Changelog sections and suggests the next semver version.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install --global @chamoji/cli
9
+ ```
10
+
11
+ Or run it without installing: `npx @chamoji/cli changelog`.
12
+
13
+ Requires Node.js 22 or newer and git.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ chamoji changelog # Markdown to stdout
19
+ chamoji changelog --output CHANGELOG.md # write a file
20
+ chamoji changelog --format html --output changelog.html
21
+ chamoji changelog --format json # machine-readable
22
+ chamoji next-version # e.g. 1.3.0
23
+ ```
24
+
25
+ `next-version` exits with code 3 and prints nothing to stdout when no commit since the last tag implies a version bump (only docs, tests or refactors, say).
26
+
27
+ | Option | Meaning |
28
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | `--format` | `markdown` (default), `md`, `json` or `html` |
30
+ | `--output`, `-o` | Write to a file instead of stdout |
31
+ | `--from <tag>` | Leave out this release and everything older |
32
+ | `--to <ref>` | Ref to treat as the head of history (default `HEAD`) |
33
+ | `--release <version>` | Name and date the unreleased section, e.g. when cutting a release. Its compare link targets the tag it will get (`v<version>` when your tags use a `v`) |
34
+ | `--config <file>` | Config file (default `chamoji.config.json` when present) |
35
+ | `--ai` | Sort the commits no rule can place with [Jev](https://typesafe.ai) (see below) |
36
+
37
+ Typical release flow:
38
+
39
+ ```bash
40
+ set -e
41
+ version=$(chamoji next-version) # stops here when nothing needs releasing
42
+ chamoji changelog --release "$version" --output CHANGELOG.md
43
+ git commit -am "🔖 Release v$version"
44
+ git tag "v$version"
45
+ ```
46
+
47
+ ## How commits are read
48
+
49
+ - **Releases** are semver tags reachable from `--to` (`v1.2.0` or `1.2.0`, prereleases included), ordered by semver. Commits after the newest tag form the Unreleased section.
50
+ - **Merge commits** are skipped.
51
+ - **`git revert` commits** (`Revert "✨ Add login"`) are listed as ⏪ reverts, not by the emoji of the commit they undo.
52
+ - **The gitmoji** can be a character (`✨`, with or without the invisible U+FE0F variation selector) or a shortcode (`:sparkles:`). Chamoji uses the earliest one in the subject. The list comes from the official [`gitmojis`](https://www.npmjs.com/package/gitmojis) package.
53
+ - **These subject forms all work:**
54
+ - `✨ Add login`
55
+ - `✨ (auth): Add login`
56
+ - `feat(auth): ✨ Add login`
57
+ - `feat: Add login`. A Conventional type without an emoji maps to one: `feat`→✨, `fix`→🐛, `docs`→📝, and so on.
58
+ - **Plain messages** are sorted by their wording:
59
+ - "Add …" goes to Added, "Fix …" to Fixed, "Remove …" to Removed, "Deprecate …" to Deprecated.
60
+ - Messages about docs, typos, tests, CI, performance, security and refactoring each go to their own section.
61
+ - Version-only commits (`1.2.1`, `Release 3.0.0`) are hidden.
62
+ - "Require Node.js 20" counts as a breaking change.
63
+ - Chamoji doesn't add emojis to plain messages, so they appear as written.
64
+ - **Dependency updates** (⬆️ ⬇️ 📌 ➕ ➖, `chore(deps):`, `fix(deps):` and Dependabot or Renovate subjects) are collapsed into a single line, such as `⬆️ 47 dependency updates: jest, vite, … and 12 more`. The JSON output keeps every commit.
65
+ - **Breaking changes** are 💥, a `!` after any type (`feat!:`, `deps!:`), or a `BREAKING CHANGE:` footer. They are listed under Breaking changes and give a major bump.
66
+ - **The next version** comes from each gitmoji's `semver` field (✨ minor, 🐛 patch, …).
67
+ - **Commit trailers** (`Key: value` lines at the end of a message) are parsed and included in the JSON output.
68
+
69
+ ## Sorting the rest with AI (`--ai`)
70
+
71
+ Some messages are too vague for rules, such as "Accept camelCase flags" or "Tidelift tasks". `--ai` sends just those commits to [Jev](https://typesafe.ai), TypeSafe's fast classification model. It picks a gitmoji for each one. Picks below 60% confidence are dropped, and those commits stay in Miscellaneous.
72
+
73
+ ```bash
74
+ export TYPESAFE_API_KEY=... # from https://typesafe.ai
75
+ chamoji changelog --ai
76
+ ```
77
+
78
+ - **What is sent:** only the commits no rule could sort, at most 300 characters each of subject and body, with email addresses and trailer lines such as `Signed-off-by:` removed. Never diffs, code or author details. `next-version --ai` sends only unreleased commits.
79
+ - **If a request fails:** chamoji stops sending, keeps the picks it already has, and leaves the rest in Miscellaneous.
80
+ - **What it never does:** override an emoji or type the author wrote, or mark a change as breaking. Only authors declare breaking changes.
81
+ - **Without `--ai`:** chamoji makes no network calls.
82
+
83
+ ## Config
84
+
85
+ `chamoji.config.json`:
86
+
87
+ ```json
88
+ {
89
+ "repositoryUrl": "https://github.com/acme/widgets",
90
+ "groups": [
91
+ {"group": "added", "label": "Added", "emojis": ["sparkles", "tada"]},
92
+ {"group": "fixed", "label": "Fixed", "emojis": ["bug", "ambulance"]},
93
+ {
94
+ "group": "useless",
95
+ "label": "Useless",
96
+ "emojis": ["bookmark"],
97
+ "hidden": true
98
+ },
99
+ {"group": "misc", "label": "Miscellaneous", "emojis": []}
100
+ ]
101
+ }
102
+ ```
103
+
104
+ - `repositoryUrl` defaults to the `origin` remote. It is used for commit, issue (`#12`) and compare links.
105
+ - `groups` replaces the default sections. Emojis are gitmoji codes without colons. Commits that match no group go to `misc` when that group exists. Hidden groups are left out of the output, and `"collapse": true` renders a group as one summary line, as the default Dependencies group does.
106
+ - A plain JSON array of groups, the older format, is also accepted.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ pnpm install
112
+ pnpm test # vitest
113
+ pnpm check # build (type-check) + prettier
114
+ ```
115
+
116
+ `packages/core` is the engine: git reading, parsing, grouping and rendering, with no UI. `packages/cli` is the `chamoji` command. See `docs/research/` for where the project is heading.
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.