stet-cli 0.2.0 → 0.3.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/CHANGELOG.md +6 -0
- package/README.md +24 -1
- package/bin/stet.js +44 -5
- package/lib/core.js +5 -1
- package/lib/linear.js +86 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-11-14
|
|
4
|
+
- Linear integration (optional, needs `LINEAR_API_KEY`): `stet ticket LIN-142` fetches a ticket; `stet scope set LIN-142 --allow …` uses the Linear title as the task.
|
|
5
|
+
- The ticket (title, status, priority, labels, link, description) is included at the top of `stet context`, so one paste gives your AI the ticket and the code.
|
|
6
|
+
- `stet pr` links the ticket ID to Linear.
|
|
7
|
+
- Privacy: only the ticket ID is sent to Linear; tickets are cached in `.stet/.cache/` (gitignored).
|
|
8
|
+
|
|
3
9
|
## 0.2.0 — 2026-11-14
|
|
4
10
|
- New `stet pr`: writes the commit message and PR description from facts — the diff since the scope was set, the ticket ID, scope result, `stet check` results and decisions logged during the task. `--check` runs the locks first, `--base main` compares against a branch.
|
|
5
11
|
- Ticket IDs: `stet scope set "LIN-142 Add CSV export" …` (or `--ticket LIN-142`) stores the ticket. It appears in AGENTS.md, context packs, guard output and PR text.
|
package/README.md
CHANGED
|
@@ -9,11 +9,13 @@ Stet is a small, free command-line tool that gives every AI coding tool (Claude
|
|
|
9
9
|
- **Scope guard.** Agree the task and the files it may touch. `stet guard` flags every file changed outside scope or in protected paths, even if the agent already committed it.
|
|
10
10
|
- **Git-synced context.** `stet context` builds a context pack from the live repo (charter, scope, locks, decisions, commits, uncommitted changes, file tree and the files you choose). Paste it into any chat instead of uploading your `src` folder.
|
|
11
11
|
- **Decision log.** `stet decide` records "we chose X because Y", so the next session doesn't reopen it.
|
|
12
|
+
- **Linear tickets.** `stet scope set LIN-142 --allow "…"` pulls the ticket's title and description from Linear into the context pack, so your AI gets the ticket and the code in one paste.
|
|
12
13
|
- **Ticket-aware PRs.** Put the ticket ID in the scope (`LIN-142 Add CSV export`) and `stet pr` writes the commit message and PR description from the real diff, scope, check results and decisions. The git hook adds the ticket ID to every commit.
|
|
13
14
|
- **Handoff pack.** `stet handoff` writes an onboarding document for a new developer or CTO, built from the repo.
|
|
14
15
|
- **MCP server.** `stet mcp` exposes all of this as tools to Claude Code, Cursor and any MCP client.
|
|
15
16
|
|
|
16
17
|
No account. No telemetry. Your code never leaves your machine. Zero dependencies. Node 18+.
|
|
18
|
+
The only network call is optional: if you set `LINEAR_API_KEY`, `stet ticket` fetches the Linear ticket you name (only the ticket ID is sent).
|
|
17
19
|
|
|
18
20
|
## Install
|
|
19
21
|
|
|
@@ -95,6 +97,7 @@ Checking 2 locked behaviours
|
|
|
95
97
|
| `stet context [--files "<globs>"] [--changed] [--diff] [--copy\|--stdout\|--out f]` | Build the context pack. By default it writes `.stet/context.md` and copies it to the clipboard. |
|
|
96
98
|
| `stet decide "<decision>" [--why "<reason>"]` | Add to the decision log. |
|
|
97
99
|
| `stet decisions [--all]` | List decisions. |
|
|
100
|
+
| `stet ticket [LIN-142] [--stdout] [--cached]` | Fetch a Linear ticket (needs `LINEAR_API_KEY`). It is included in `stet context` and linked in `stet pr`. With no ID, shows the current scope's ticket. |
|
|
98
101
|
| `stet pr [--base main] [--check] [--stdout]` | Write `.stet/.cache/commit.txt` and `.stet/.cache/pr.md` from the diff since the scope was set: ticket, changed files, scope result, `stet check` results and decisions logged during the task. `--check` runs the locks first. |
|
|
99
102
|
| `stet handoff [--out HANDOFF.md] [--stdout]` | Generate the onboarding pack. |
|
|
100
103
|
| `stet hook install [--check]` | Add git hooks: pre-commit blocks commits that stage out-of-scope or protected files (`--check` also runs `stet check`); prepare-commit-msg adds the ticket ID to commit messages. |
|
|
@@ -103,6 +106,26 @@ Checking 2 locked behaviours
|
|
|
103
106
|
|
|
104
107
|
Add `--json` to any command for machine-readable output.
|
|
105
108
|
|
|
109
|
+
## Linear
|
|
110
|
+
|
|
111
|
+
1. In Linear: **Settings → Security & access → Personal API keys → New key** (read access is enough).
|
|
112
|
+
2. Put it in your shell, never in the repo:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
echo 'export LINEAR_API_KEY=lin_api_...' >> ~/.zshrc && source ~/.zshrc
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
3. Work from tickets:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
stet scope set LIN-142 --allow "src/export/**,tests/export/**" # task = the Linear title
|
|
122
|
+
stet context --files "src/export/**" # ticket + rules + code, ready to paste into ChatGPT
|
|
123
|
+
stet ticket # show the current ticket again (stet ticket LIN-150 for another)
|
|
124
|
+
stet pr # PR text links back to the Linear ticket
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Tickets are cached in `.stet/.cache/` (gitignored). Only the ticket ID is sent to Linear; nothing about your code is.
|
|
128
|
+
|
|
106
129
|
## Use with your AI tools
|
|
107
130
|
|
|
108
131
|
**Claude Code:** run `stet init --mcp`, or `claude mcp add stet -- npx -y stet-cli mcp`. Claude Code reads `CLAUDE.md`, which imports `AGENTS.md`.
|
|
@@ -144,7 +167,7 @@ Globs support `*`, `**` and `?`. A bare folder name like `migrations` matches ev
|
|
|
144
167
|
|
|
145
168
|
## FAQ
|
|
146
169
|
|
|
147
|
-
**Does Stet send my code anywhere?** No. It's a local CLI with no network
|
|
170
|
+
**Does Stet send my code anywhere?** No. It's a local CLI with no telemetry. Its only network call is the optional Linear fetch, which sends just the ticket ID you ask for.
|
|
148
171
|
|
|
149
172
|
**Is this just AGENTS.md?** `AGENTS.md` tells the agent the rules. Stet keeps those rules current, and `guard` and `check` verify the agent actually followed them.
|
|
150
173
|
|
package/bin/stet.js
CHANGED
|
@@ -5,7 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const core = require('../lib/core');
|
|
6
6
|
const { c } = core;
|
|
7
7
|
|
|
8
|
-
const BOOL = new Set(['help', 'h', 'version', 'v', 'staged', 'warn', 'diff', 'changed', 'stdout', 'copy', 'all', 'mcp', 'uninstall', 'check', 'json', 'no-color']);
|
|
8
|
+
const BOOL = new Set(['help', 'h', 'version', 'v', 'staged', 'warn', 'diff', 'changed', 'stdout', 'copy', 'all', 'mcp', 'uninstall', 'check', 'json', 'no-color', 'cached']);
|
|
9
9
|
function parse(argv) {
|
|
10
10
|
const pos = [], fl = {};
|
|
11
11
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -38,6 +38,10 @@ ${c.bold('After an AI task')}
|
|
|
38
38
|
stet guard [--staged] [--base ref] [--warn] Flag changes outside scope or in protected paths
|
|
39
39
|
stet check [--only L1,L2] Run locked behaviours; non-zero exit if any broke
|
|
40
40
|
|
|
41
|
+
${c.bold('Linear')} (optional · needs LINEAR_API_KEY in your shell)
|
|
42
|
+
stet ticket LIN-142 [--stdout|--json] Fetch a ticket; it goes into stet context and stet pr
|
|
43
|
+
stet scope set LIN-142 --allow "<globs>" Uses the Linear title as the task
|
|
44
|
+
|
|
41
45
|
${c.bold('Shipping')}
|
|
42
46
|
stet pr [--base main] [--check] [--stdout]
|
|
43
47
|
Commit message + PR description from the diff, scope, checks and decisions
|
|
@@ -58,7 +62,7 @@ ${c.bold('Team')}
|
|
|
58
62
|
|
|
59
63
|
Docs: https://stet-8fe08a58.base44.app`;
|
|
60
64
|
|
|
61
|
-
function main() {
|
|
65
|
+
async function main() {
|
|
62
66
|
const { pos, fl } = parse(process.argv.slice(2));
|
|
63
67
|
if (fl['no-color']) core.setColor(false);
|
|
64
68
|
const [cmd, sub, ...rest] = pos;
|
|
@@ -83,7 +87,23 @@ function main() {
|
|
|
83
87
|
case 'check': res = core.check(r, { only: fl.only }); break;
|
|
84
88
|
case 'scope':
|
|
85
89
|
if (!sub || sub === 'show') res = core.scopeShow(r);
|
|
86
|
-
else if (sub === 'set')
|
|
90
|
+
else if (sub === 'set') {
|
|
91
|
+
let task = rest.join(' ');
|
|
92
|
+
const id = (typeof fl.ticket === 'string' ? fl.ticket : (task.match(/\b[A-Z][A-Z0-9]{1,9}-\d+\b/) || [])[0] || '').toUpperCase();
|
|
93
|
+
const notes = [];
|
|
94
|
+
// With LINEAR_API_KEY set, fetch the ticket so its title and description reach the context pack.
|
|
95
|
+
if (id && process.env.LINEAR_API_KEY) {
|
|
96
|
+
try {
|
|
97
|
+
const linear = require('../lib/linear');
|
|
98
|
+
const issue = await linear.fetchIssue(id);
|
|
99
|
+
linear.saveIssue(r, issue);
|
|
100
|
+
if (task.trim().toUpperCase() === id) task = `${id} ${issue.title}`; // "stet scope set LIN-142" → use the Linear title
|
|
101
|
+
notes.push(`${c.green('✓')} Linear: ${issue.title}${issue.state ? c.dim(' · ' + issue.state.name) : ''}`);
|
|
102
|
+
} catch (e) { notes.push(c.yellow(`! Linear: ${e.message.split('\n')[0]} (scope set anyway)`)); }
|
|
103
|
+
}
|
|
104
|
+
res = core.scopeSet(r, task, { allow: fl.allow, ticket: fl.ticket });
|
|
105
|
+
if (notes.length) res.text += '\n' + notes.map((n) => ' ' + n).join('\n');
|
|
106
|
+
}
|
|
87
107
|
else if (sub === 'ticket') res = core.scopeTicket(r);
|
|
88
108
|
else if (sub === 'clear') res = core.scopeClear(r);
|
|
89
109
|
else throw new core.StetError('Usage: stet scope [show|set|clear|ticket]');
|
|
@@ -128,6 +148,25 @@ function main() {
|
|
|
128
148
|
].join('\n') };
|
|
129
149
|
break;
|
|
130
150
|
}
|
|
151
|
+
case 'ticket': {
|
|
152
|
+
const linear = require('../lib/linear');
|
|
153
|
+
r.need();
|
|
154
|
+
const id = (sub || r.scope.ticket || '').toUpperCase();
|
|
155
|
+
if (!id) throw new core.StetError('Usage: stet ticket LIN-142 (or set a scope with a ticket ID first)');
|
|
156
|
+
let issue = fl.cached ? linear.cachedIssue(r, id) : null;
|
|
157
|
+
if (!issue) { issue = await linear.fetchIssue(id); linear.saveIssue(r, issue); }
|
|
158
|
+
if (fl.stdout) { process.stdout.write(linear.issueMd(issue) + '\n'); return; }
|
|
159
|
+
const body = linear.issueMd(issue).split('\n');
|
|
160
|
+
res = { code: 0, text: [
|
|
161
|
+
`${c.green('✓')} ${c.violet(issue.identifier)} ${c.bold(issue.title)}`,
|
|
162
|
+
...body.slice(1, 3).filter(Boolean).map((l) => ' ' + c.dim(l)),
|
|
163
|
+
'', ...body.slice(3).join('\n').trim().split('\n').slice(0, 12).map((l) => ' ' + l),
|
|
164
|
+
].join('\n'), data: issue };
|
|
165
|
+
res.text += '\n' + [
|
|
166
|
+
'', c.dim(` Saved for this repo. ${r.scope.ticket === issue.identifier ? 'It is included in stet context and stet pr.' : `Start work on it: stet scope set ${issue.identifier} --allow "<globs>"`}`),
|
|
167
|
+
].join('\n');
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
131
170
|
case 'status': case 'st': res = core.status(r); break;
|
|
132
171
|
case 'hook':
|
|
133
172
|
if (sub === 'install') res = core.hook(r, { withCheck: fl.check });
|
|
@@ -144,7 +183,7 @@ function main() {
|
|
|
144
183
|
process.exitCode = res.code;
|
|
145
184
|
}
|
|
146
185
|
|
|
147
|
-
|
|
186
|
+
main().catch((e) => {
|
|
148
187
|
if (e instanceof core.StetError) { console.error(c.red('stet: ') + e.message); process.exitCode = e.code || 1; }
|
|
149
188
|
else { console.error(e); process.exitCode = 2; }
|
|
150
|
-
}
|
|
189
|
+
});
|
package/lib/core.js
CHANGED
|
@@ -491,6 +491,9 @@ function context(r, { files, changed, diff, maxKB } = {}) {
|
|
|
491
491
|
parts.push(`# Context pack — ${cfg.name}`);
|
|
492
492
|
parts.push(`Generated by Stet ${VERSION} on ${new Date().toISOString().replace('T', ' ').slice(0, 16)} UTC · branch \`${g ? r.branch() : 'n/a'}\` · commit \`${g ? r.head() || 'none' : 'n/a'}\` · ${status.length} uncommitted change${status.length === 1 ? '' : 's'} · ${all.length} files`);
|
|
493
493
|
parts.push(`> **For the AI reading this:** this is the live state of the repository right now. It replaces any code, file list or assumption from earlier in this conversation. Follow the working agreement below. Do not invent files that are not in the tree. If you need a file's contents, ask for it by path.`);
|
|
494
|
+
// The Linear ticket (if fetched with `stet ticket` / `stet scope set LIN-142`) goes first: it is the task.
|
|
495
|
+
const tk = r.scope.ticket && require('./linear').cachedIssue(r, r.scope.ticket);
|
|
496
|
+
if (tk) parts.push(require('./linear').issueMd(tk));
|
|
494
497
|
parts.push(agentsBlock(r).replace(BEGIN, '').replace(END, '').replace(/^<!--.*-->$/m, '').replace(/^### /gm, '#### ').replace(/^# Working agreement.*$/m, '## Working agreement').replace(/^## (?!Working)/gm, '### ').trim());
|
|
495
498
|
if (g && r.head()) parts.push(`## Recent commits\n\`\`\`\n${r.tgit(['log', '--oneline', '--no-decorate', '-15']).trim()}\n\`\`\``);
|
|
496
499
|
if (status.length) parts.push(`## Uncommitted changes\n\`\`\`\n${status.join('\n')}\n\`\`\``);
|
|
@@ -646,7 +649,8 @@ function pr(r, { base, runCheck } = {}) {
|
|
|
646
649
|
|
|
647
650
|
const md = [];
|
|
648
651
|
md.push(`## ${title}`, '');
|
|
649
|
-
|
|
652
|
+
const tk = s.ticket && require('./linear').cachedIssue(r, s.ticket);
|
|
653
|
+
if (s.ticket) md.push(`**Ticket:** ${tk && tk.url ? `[${s.ticket}](${tk.url})` : s.ticket}`, '');
|
|
650
654
|
md.push('### What and why', '<!-- Replace this line with 1–2 sentences: what changes for the user, and why. -->', s.task ? `${s.task}.` : '', '');
|
|
651
655
|
md.push(`### Changes · ${files.length} file${files.length === 1 ? '' : 's'}, +${plus} −${minus}`);
|
|
652
656
|
if (files.length) {
|
package/lib/linear.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Optional Linear integration.
|
|
3
|
+
// The only network call Stet makes: when you ask for a ticket and LINEAR_API_KEY is set,
|
|
4
|
+
// it fetches that one issue from Linear. Nothing about your code is ever sent.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { StetError } = require('./core');
|
|
8
|
+
|
|
9
|
+
const LINEAR_URL = process.env.STET_LINEAR_URL || 'https://api.linear.app/graphql';
|
|
10
|
+
const ID_RE = /^[A-Z][A-Z0-9]{1,9}-\d+$/;
|
|
11
|
+
|
|
12
|
+
const QUERY = `query StetIssue($id: String!) {
|
|
13
|
+
issue(id: $id) {
|
|
14
|
+
identifier title description url priorityLabel
|
|
15
|
+
state { name } assignee { name } project { name }
|
|
16
|
+
labels { nodes { name } }
|
|
17
|
+
}
|
|
18
|
+
}`;
|
|
19
|
+
|
|
20
|
+
function apiKey() {
|
|
21
|
+
const k = (process.env.LINEAR_API_KEY || '').trim();
|
|
22
|
+
if (!k) {
|
|
23
|
+
throw new StetError(
|
|
24
|
+
'LINEAR_API_KEY is not set.\n' +
|
|
25
|
+
' 1. In Linear: Settings → Security & access → Personal API keys → New key (read access is enough)\n' +
|
|
26
|
+
' 2. Add it to your shell, not to the repo: echo \'export LINEAR_API_KEY=lin_api_...\' >> ~/.zshrc && source ~/.zshrc'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return k;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Personal API keys go in the header as-is; OAuth tokens need "Bearer".
|
|
33
|
+
const authHeader = (k) => (k.startsWith('lin_oauth') ? `Bearer ${k}` : k);
|
|
34
|
+
|
|
35
|
+
async function fetchIssue(id) {
|
|
36
|
+
id = String(id || '').trim().toUpperCase();
|
|
37
|
+
if (!ID_RE.test(id)) throw new StetError(`"${id}" doesn't look like a Linear ticket ID (expected something like LIN-142).`);
|
|
38
|
+
const key = apiKey();
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await fetch(LINEAR_URL, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json', Authorization: authHeader(key) },
|
|
44
|
+
body: JSON.stringify({ query: QUERY, variables: { id } }),
|
|
45
|
+
});
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new StetError(`Couldn't reach Linear (${e.cause?.code || e.message}). Check your internet connection.`);
|
|
48
|
+
}
|
|
49
|
+
if (res.status === 401 || res.status === 403) throw new StetError('Linear rejected the API key. Check LINEAR_API_KEY (Settings → Security & access → Personal API keys).');
|
|
50
|
+
let body;
|
|
51
|
+
try { body = await res.json(); } catch { throw new StetError(`Linear returned an unexpected response (HTTP ${res.status}).`); }
|
|
52
|
+
const issue = body && body.data && body.data.issue;
|
|
53
|
+
if (!issue) {
|
|
54
|
+
const msg = (body && body.errors && body.errors[0] && body.errors[0].message) || 'not found';
|
|
55
|
+
throw new StetError(`Linear ticket ${id}: ${msg}. Check the ID and that your key's workspace can see it.`);
|
|
56
|
+
}
|
|
57
|
+
return issue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Cache lives in .stet/.cache (gitignored), so ticket text never ends up in the repo.
|
|
61
|
+
const cachePath = (r, id) => r.p('.cache', 'tickets', `${id}.json`);
|
|
62
|
+
function saveIssue(r, issue) {
|
|
63
|
+
const f = cachePath(r, issue.identifier);
|
|
64
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
65
|
+
fs.writeFileSync(f, JSON.stringify({ ...issue, fetchedAt: new Date().toISOString() }, null, 2) + '\n');
|
|
66
|
+
}
|
|
67
|
+
function cachedIssue(r, id) {
|
|
68
|
+
if (!id) return null;
|
|
69
|
+
try { return JSON.parse(fs.readFileSync(cachePath(r, id), 'utf8')); } catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Markdown block used in the context pack and `stet ticket` output.
|
|
73
|
+
function issueMd(issue, { maxChars = 8000 } = {}) {
|
|
74
|
+
const meta = [
|
|
75
|
+
issue.state && issue.state.name && `Status: ${issue.state.name}`,
|
|
76
|
+
issue.priorityLabel && issue.priorityLabel !== 'No priority' && `Priority: ${issue.priorityLabel}`,
|
|
77
|
+
issue.assignee && issue.assignee.name && `Assignee: ${issue.assignee.name}`,
|
|
78
|
+
issue.project && issue.project.name && `Project: ${issue.project.name}`,
|
|
79
|
+
issue.labels && issue.labels.nodes && issue.labels.nodes.length && `Labels: ${issue.labels.nodes.map((l) => l.name).join(', ')}`,
|
|
80
|
+
].filter(Boolean).join(' · ');
|
|
81
|
+
let desc = (issue.description || '').trim() || '_No description in Linear._';
|
|
82
|
+
if (desc.length > maxChars) desc = desc.slice(0, maxChars) + '\n\n… (ticket description truncated)';
|
|
83
|
+
return `## Ticket ${issue.identifier}: ${issue.title}\n${meta ? meta + '\n' : ''}${issue.url ? `Link: ${issue.url}\n` : ''}\n${desc}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { fetchIssue, saveIssue, cachedIssue, issueMd, ID_RE };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stet-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Keep AI coding agents from breaking what already works. Charter, locked behaviours, scope guard, decision log and git-synced context for Claude Code, Cursor, Codex and any chat.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"stet": "bin/stet.js"
|