tissues 0.5.2 → 0.6.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/README.md +94 -40
- package/package.json +3 -4
- package/src/cli.js +26 -22
- package/src/commands/ai.js +268 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +1035 -12
- package/src/commands/create.js +523 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +68 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +75 -0
- package/src/lib/ai/enhance.js +107 -0
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +97 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +216 -0
- package/src/lib/ai/steps.js +492 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +67 -32
- package/src/lib/defaults.js +54 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +102 -21
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhancement definitions — first-class pipeline step descriptors.
|
|
3
|
+
*
|
|
4
|
+
* Three-tier priority (same as templates): repo > user > built-in.
|
|
5
|
+
* File format: YAML-ish frontmatter + system prompt body (.md files).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import { findRepoRoot, loadConfig } from './defaults.js'
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Built-in enhancements (extracted from steps.js prompts)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export const BUILT_IN_ENHANCEMENTS = {
|
|
18
|
+
triage: {
|
|
19
|
+
key: 'triage',
|
|
20
|
+
name: 'Input Analysis',
|
|
21
|
+
maxTokens: 1024,
|
|
22
|
+
mode: 'always',
|
|
23
|
+
format: 'json',
|
|
24
|
+
contextKey: 'triage',
|
|
25
|
+
order: 10,
|
|
26
|
+
requires: [],
|
|
27
|
+
isStructural: true,
|
|
28
|
+
provider: null,
|
|
29
|
+
prompt: [
|
|
30
|
+
'You extract a structured GitHub issue title and description from freeform user input.',
|
|
31
|
+
'Return a JSON object with:',
|
|
32
|
+
' title: string — concise issue title, ≤80 characters, imperative mood (e.g. "Fix login timeout on Safari")',
|
|
33
|
+
' description: string — structured description expanding on the input, in markdown',
|
|
34
|
+
'The title must capture the core intent. The description should organize and expand the raw input into clear context.',
|
|
35
|
+
'Return ONLY valid JSON.',
|
|
36
|
+
].join('\n'),
|
|
37
|
+
},
|
|
38
|
+
dedup: {
|
|
39
|
+
key: 'dedup',
|
|
40
|
+
name: 'Duplicate Check',
|
|
41
|
+
maxTokens: 1024,
|
|
42
|
+
mode: 'auto',
|
|
43
|
+
format: 'json',
|
|
44
|
+
contextKey: 'dedupScore',
|
|
45
|
+
order: 15,
|
|
46
|
+
requires: [],
|
|
47
|
+
isStructural: false,
|
|
48
|
+
provider: null,
|
|
49
|
+
prompt: [
|
|
50
|
+
'You are a duplicate detection system for GitHub issues.',
|
|
51
|
+
'Compare the new issue against existing open issues.',
|
|
52
|
+
'Return a JSON object with:',
|
|
53
|
+
' confidence: number 0-100 (how likely this is a duplicate)',
|
|
54
|
+
' level: "high" | "medium" | "low" | "none"',
|
|
55
|
+
' matches: array of { number, reason } for similar issues',
|
|
56
|
+
'Return ONLY valid JSON.',
|
|
57
|
+
].join('\n'),
|
|
58
|
+
},
|
|
59
|
+
context: {
|
|
60
|
+
key: 'context',
|
|
61
|
+
name: 'Context Extraction',
|
|
62
|
+
maxTokens: 1024,
|
|
63
|
+
mode: 'always',
|
|
64
|
+
format: 'json',
|
|
65
|
+
contextKey: 'structuredContext',
|
|
66
|
+
order: 20,
|
|
67
|
+
requires: [],
|
|
68
|
+
isStructural: false,
|
|
69
|
+
provider: null,
|
|
70
|
+
prompt: [
|
|
71
|
+
'You are an expert at understanding software issues.',
|
|
72
|
+
'Extract structured context from the issue description.',
|
|
73
|
+
'Return a JSON object with:',
|
|
74
|
+
' problem: string — the core problem in one sentence',
|
|
75
|
+
' files: string[] — file paths mentioned or implied',
|
|
76
|
+
' errors: string[] — error messages mentioned',
|
|
77
|
+
' sessionContext: string — any relevant session/environment context',
|
|
78
|
+
'Return ONLY valid JSON.',
|
|
79
|
+
].join('\n'),
|
|
80
|
+
},
|
|
81
|
+
scope: {
|
|
82
|
+
key: 'scope',
|
|
83
|
+
name: 'Scope Analysis',
|
|
84
|
+
maxTokens: 1024,
|
|
85
|
+
mode: 'auto',
|
|
86
|
+
format: 'json',
|
|
87
|
+
contextKey: 'scopeAnalysis',
|
|
88
|
+
order: 30,
|
|
89
|
+
requires: [],
|
|
90
|
+
isStructural: false,
|
|
91
|
+
provider: null,
|
|
92
|
+
prompt: [
|
|
93
|
+
'You are a software scope analyzer.',
|
|
94
|
+
'Given an issue description and context, identify the files and areas affected.',
|
|
95
|
+
'Return a JSON object with:',
|
|
96
|
+
' files: array of { path: string, purpose: string, deps: string[] }',
|
|
97
|
+
' affectedAreas: string[] — high-level areas (e.g. "auth", "UI", "database")',
|
|
98
|
+
'Return ONLY valid JSON.',
|
|
99
|
+
].join('\n'),
|
|
100
|
+
},
|
|
101
|
+
complexity: {
|
|
102
|
+
key: 'complexity',
|
|
103
|
+
name: 'Complexity Scoring',
|
|
104
|
+
maxTokens: 512,
|
|
105
|
+
mode: 'auto',
|
|
106
|
+
format: 'json',
|
|
107
|
+
contextKey: 'complexity',
|
|
108
|
+
order: 40,
|
|
109
|
+
requires: [],
|
|
110
|
+
isStructural: false,
|
|
111
|
+
provider: null,
|
|
112
|
+
prompt: [
|
|
113
|
+
'You assess implementation complexity for GitHub issues.',
|
|
114
|
+
'Return a JSON object with:',
|
|
115
|
+
' score: number 1-10 (1=trivial, 10=massive refactor)',
|
|
116
|
+
' rationale: string — one sentence explaining the score',
|
|
117
|
+
'Return ONLY valid JSON.',
|
|
118
|
+
].join('\n'),
|
|
119
|
+
},
|
|
120
|
+
risk: {
|
|
121
|
+
key: 'risk',
|
|
122
|
+
name: 'Risk Assessment',
|
|
123
|
+
maxTokens: 512,
|
|
124
|
+
mode: 'auto',
|
|
125
|
+
format: 'json',
|
|
126
|
+
contextKey: 'risk',
|
|
127
|
+
order: 50,
|
|
128
|
+
requires: [],
|
|
129
|
+
isStructural: false,
|
|
130
|
+
provider: null,
|
|
131
|
+
prompt: [
|
|
132
|
+
'You assess implementation risk for GitHub issues.',
|
|
133
|
+
'Consider: breaking changes, data loss potential, security implications, blast radius.',
|
|
134
|
+
'Return a JSON object with:',
|
|
135
|
+
' score: number 1-10 (1=no risk, 10=extremely risky)',
|
|
136
|
+
' rationale: string — one sentence explaining the score',
|
|
137
|
+
'Return ONLY valid JSON.',
|
|
138
|
+
].join('\n'),
|
|
139
|
+
},
|
|
140
|
+
labels: {
|
|
141
|
+
key: 'labels',
|
|
142
|
+
name: 'Label Suggestion',
|
|
143
|
+
maxTokens: 512,
|
|
144
|
+
mode: 'auto',
|
|
145
|
+
format: 'json',
|
|
146
|
+
contextKey: 'aiLabels',
|
|
147
|
+
order: 60,
|
|
148
|
+
requires: [],
|
|
149
|
+
isStructural: false,
|
|
150
|
+
provider: null,
|
|
151
|
+
prompt: [
|
|
152
|
+
'You suggest GitHub labels for issues.',
|
|
153
|
+
'Return a JSON object with:',
|
|
154
|
+
' labels: string[] — labels to apply (must be from the available list)',
|
|
155
|
+
' reasoning: string — brief explanation',
|
|
156
|
+
'Only suggest labels that genuinely fit. Return ONLY valid JSON.',
|
|
157
|
+
].join('\n'),
|
|
158
|
+
},
|
|
159
|
+
format: {
|
|
160
|
+
key: 'format',
|
|
161
|
+
name: 'Body Formatting',
|
|
162
|
+
maxTokens: 4096,
|
|
163
|
+
mode: 'always',
|
|
164
|
+
format: 'markdown',
|
|
165
|
+
contextKey: 'body',
|
|
166
|
+
order: 90,
|
|
167
|
+
requires: [],
|
|
168
|
+
isStructural: true,
|
|
169
|
+
provider: null,
|
|
170
|
+
prompt: [
|
|
171
|
+
'You are an expert at writing clear, well-structured GitHub issues.',
|
|
172
|
+
'Given an issue title, description, and analysis from prior steps,',
|
|
173
|
+
'write a complete issue body in markdown.',
|
|
174
|
+
].join('\n'),
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Return the list of built-in enhancement keys.
|
|
180
|
+
* @returns {string[]}
|
|
181
|
+
*/
|
|
182
|
+
export function builtInEnhancementKeys() {
|
|
183
|
+
return Object.keys(BUILT_IN_ENHANCEMENTS)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Frontmatter parser
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse an enhancement file: YAML-ish frontmatter + system prompt body.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} content - raw file content
|
|
194
|
+
* @returns {{ meta: object, prompt: string }}
|
|
195
|
+
*/
|
|
196
|
+
export function parseEnhancementFile(content) {
|
|
197
|
+
const meta = {}
|
|
198
|
+
let prompt = content
|
|
199
|
+
|
|
200
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/)
|
|
201
|
+
if (fmMatch) {
|
|
202
|
+
const frontmatter = fmMatch[1]
|
|
203
|
+
prompt = fmMatch[2].trim()
|
|
204
|
+
|
|
205
|
+
for (const line of frontmatter.split('\n')) {
|
|
206
|
+
const m = line.match(/^(\w+):\s*(.+)$/)
|
|
207
|
+
if (!m) continue
|
|
208
|
+
const [, key, rawVal] = m
|
|
209
|
+
let val = rawVal.trim()
|
|
210
|
+
|
|
211
|
+
// Parse arrays: ["a", "b"] or [a, b]
|
|
212
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
213
|
+
val = val.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
|
|
214
|
+
} else if (val === 'true') {
|
|
215
|
+
val = true
|
|
216
|
+
} else if (val === 'false') {
|
|
217
|
+
val = false
|
|
218
|
+
} else if (/^\d+$/.test(val)) {
|
|
219
|
+
val = Number(val)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
meta[key] = val
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { meta, prompt }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Helpers
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve the enhancement directory for a given source.
|
|
235
|
+
*
|
|
236
|
+
* @param {'repo' | 'user'} source
|
|
237
|
+
* @param {string|null} repoRoot
|
|
238
|
+
* @param {object} cfg - loaded config object
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
export function resolveEnhancementDir(source, repoRoot, cfg) {
|
|
242
|
+
if (source === 'user') {
|
|
243
|
+
return path.join(os.homedir(), '.config', 'tissues', 'enhancements')
|
|
244
|
+
}
|
|
245
|
+
// repo source
|
|
246
|
+
const enhDir = cfg.enhancements?.dir ?? '.tissues/enhancements'
|
|
247
|
+
if (path.isAbsolute(enhDir)) return enhDir
|
|
248
|
+
if (repoRoot) return path.join(repoRoot, enhDir)
|
|
249
|
+
return path.resolve(enhDir)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read all `.md` files from a directory and return enhancement descriptors.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} dir
|
|
256
|
+
* @param {'repo' | 'user'} source
|
|
257
|
+
* @returns {Array<object>}
|
|
258
|
+
*/
|
|
259
|
+
function readEnhancementsFromDir(dir, source) {
|
|
260
|
+
try {
|
|
261
|
+
const entries = fs.readdirSync(dir)
|
|
262
|
+
return entries
|
|
263
|
+
.filter((f) => f.endsWith('.md'))
|
|
264
|
+
.map((f) => {
|
|
265
|
+
const key = path.basename(f, '.md')
|
|
266
|
+
const raw = fs.readFileSync(path.join(dir, f), 'utf8')
|
|
267
|
+
const { meta, prompt } = parseEnhancementFile(raw)
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
key,
|
|
271
|
+
name: meta.name || key.charAt(0).toUpperCase() + key.slice(1),
|
|
272
|
+
maxTokens: meta.maxTokens || 1024,
|
|
273
|
+
mode: meta.mode || 'auto',
|
|
274
|
+
format: meta.format || 'json',
|
|
275
|
+
contextKey: meta.contextKey || key,
|
|
276
|
+
order: meta.order != null ? meta.order : 50,
|
|
277
|
+
requires: Array.isArray(meta.requires) ? meta.requires : [],
|
|
278
|
+
isStructural: false,
|
|
279
|
+
provider: meta.provider || null,
|
|
280
|
+
prompt,
|
|
281
|
+
source,
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
} catch {
|
|
285
|
+
return []
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Public API
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* List all available enhancements from all sources.
|
|
295
|
+
*
|
|
296
|
+
* Priority (highest → lowest): repo > user > built-in
|
|
297
|
+
*
|
|
298
|
+
* @param {string} [repoRoot] - path to repo root; auto-detected if omitted
|
|
299
|
+
* @returns {Array<{ key: string, name: string, source: string, mode: string, order: number }>}
|
|
300
|
+
*/
|
|
301
|
+
export function listEnhancements(repoRoot) {
|
|
302
|
+
const root = repoRoot ?? findRepoRoot()
|
|
303
|
+
const cfg = loadConfig(root)
|
|
304
|
+
|
|
305
|
+
// Built-in (lowest priority)
|
|
306
|
+
const builtIn = Object.entries(BUILT_IN_ENHANCEMENTS).map(([key, enh]) => ({
|
|
307
|
+
key,
|
|
308
|
+
name: enh.name,
|
|
309
|
+
source: 'built-in',
|
|
310
|
+
mode: enh.mode,
|
|
311
|
+
order: enh.order,
|
|
312
|
+
provider: enh.provider || null,
|
|
313
|
+
}))
|
|
314
|
+
|
|
315
|
+
// User enhancements
|
|
316
|
+
const userDir = resolveEnhancementDir('user', root, cfg)
|
|
317
|
+
const userEnhancements = readEnhancementsFromDir(userDir, 'user').map(({ key, name, mode, order, provider }) => ({
|
|
318
|
+
key, name, source: 'user', mode, order, provider: provider || null,
|
|
319
|
+
}))
|
|
320
|
+
|
|
321
|
+
// Repo enhancements (highest priority)
|
|
322
|
+
const repoDir = resolveEnhancementDir('repo', root, cfg)
|
|
323
|
+
const repoEnhancements = readEnhancementsFromDir(repoDir, 'repo').map(({ key, name, mode, order, provider }) => ({
|
|
324
|
+
key, name, source: 'repo', mode, order, provider: provider || null,
|
|
325
|
+
}))
|
|
326
|
+
|
|
327
|
+
return [...repoEnhancements, ...userEnhancements, ...builtIn]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Load a single enhancement by key, applying source priority:
|
|
332
|
+
* repo > user > built-in
|
|
333
|
+
*
|
|
334
|
+
* @param {string} name - enhancement key
|
|
335
|
+
* @param {string} [repoRoot]
|
|
336
|
+
* @returns {object} full enhancement descriptor with prompt
|
|
337
|
+
* @throws {Error} if enhancement not found
|
|
338
|
+
*/
|
|
339
|
+
export function loadEnhancement(name, repoRoot) {
|
|
340
|
+
const root = repoRoot ?? findRepoRoot()
|
|
341
|
+
const cfg = loadConfig(root)
|
|
342
|
+
|
|
343
|
+
// 1. Repo
|
|
344
|
+
const repoDir = resolveEnhancementDir('repo', root, cfg)
|
|
345
|
+
const repoFile = path.join(repoDir, `${name}.md`)
|
|
346
|
+
if (fs.existsSync(repoFile)) {
|
|
347
|
+
const raw = fs.readFileSync(repoFile, 'utf8')
|
|
348
|
+
return buildDescriptor(name, raw, 'repo')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 2. User
|
|
352
|
+
const userDir = resolveEnhancementDir('user', root, cfg)
|
|
353
|
+
const userFile = path.join(userDir, `${name}.md`)
|
|
354
|
+
if (fs.existsSync(userFile)) {
|
|
355
|
+
const raw = fs.readFileSync(userFile, 'utf8')
|
|
356
|
+
return buildDescriptor(name, raw, 'user')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 3. Built-in
|
|
360
|
+
const builtIn = BUILT_IN_ENHANCEMENTS[name]
|
|
361
|
+
if (builtIn) {
|
|
362
|
+
return { ...builtIn, source: 'built-in' }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Enhancement "${name}" not found. ` +
|
|
367
|
+
`Available: ${Object.keys(BUILT_IN_ENHANCEMENTS).join(', ')}`,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Load all enhancements, merged by priority and sorted by order.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} [repoRoot]
|
|
375
|
+
* @returns {object[]} sorted array of enhancement descriptors
|
|
376
|
+
*/
|
|
377
|
+
export function loadAllEnhancements(repoRoot) {
|
|
378
|
+
const root = repoRoot ?? findRepoRoot()
|
|
379
|
+
const cfg = loadConfig(root)
|
|
380
|
+
|
|
381
|
+
// Start with built-ins
|
|
382
|
+
const merged = new Map()
|
|
383
|
+
for (const [key, enh] of Object.entries(BUILT_IN_ENHANCEMENTS)) {
|
|
384
|
+
merged.set(key, { ...enh, source: 'built-in' })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// User overrides
|
|
388
|
+
const userDir = resolveEnhancementDir('user', root, cfg)
|
|
389
|
+
for (const enh of readEnhancementsFromDir(userDir, 'user')) {
|
|
390
|
+
if (merged.has(enh.key)) {
|
|
391
|
+
// Override built-in but preserve isStructural
|
|
392
|
+
const builtIn = merged.get(enh.key)
|
|
393
|
+
merged.set(enh.key, { ...builtIn, ...enh, isStructural: builtIn.isStructural })
|
|
394
|
+
} else {
|
|
395
|
+
merged.set(enh.key, enh)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Repo overrides (highest priority)
|
|
400
|
+
const repoDir = resolveEnhancementDir('repo', root, cfg)
|
|
401
|
+
for (const enh of readEnhancementsFromDir(repoDir, 'repo')) {
|
|
402
|
+
if (merged.has(enh.key)) {
|
|
403
|
+
const existing = merged.get(enh.key)
|
|
404
|
+
merged.set(enh.key, { ...existing, ...enh, isStructural: existing.isStructural || false })
|
|
405
|
+
} else {
|
|
406
|
+
merged.set(enh.key, enh)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Sort by order
|
|
411
|
+
return [...merged.values()].sort((a, b) => a.order - b.order)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// Internal helpers
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
function buildDescriptor(key, rawContent, source) {
|
|
419
|
+
const { meta, prompt } = parseEnhancementFile(rawContent)
|
|
420
|
+
const builtIn = BUILT_IN_ENHANCEMENTS[key]
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
key,
|
|
424
|
+
name: meta.name || (builtIn ? builtIn.name : key.charAt(0).toUpperCase() + key.slice(1)),
|
|
425
|
+
maxTokens: meta.maxTokens || (builtIn ? builtIn.maxTokens : 1024),
|
|
426
|
+
mode: meta.mode || (builtIn ? builtIn.mode : 'auto'),
|
|
427
|
+
format: meta.format || (builtIn ? builtIn.format : 'json'),
|
|
428
|
+
contextKey: meta.contextKey || (builtIn ? builtIn.contextKey : key),
|
|
429
|
+
order: meta.order != null ? meta.order : (builtIn ? builtIn.order : 50),
|
|
430
|
+
requires: Array.isArray(meta.requires) ? meta.requires : (builtIn ? builtIn.requires : []),
|
|
431
|
+
isStructural: builtIn ? builtIn.isStructural : false,
|
|
432
|
+
provider: meta.provider || null,
|
|
433
|
+
prompt,
|
|
434
|
+
source,
|
|
435
|
+
}
|
|
436
|
+
}
|
package/src/lib/gh.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { execFileSync, execSync } from 'node:child_process'
|
|
7
|
-
import
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import nodePath from 'node:path'
|
|
9
|
+
import { red, cyan, dim } from './color.js'
|
|
8
10
|
|
|
9
11
|
// ---------------------------------------------------------------------------
|
|
10
12
|
// gh availability
|
|
@@ -22,14 +24,14 @@ export function requireGh() {
|
|
|
22
24
|
}).trim()
|
|
23
25
|
return path
|
|
24
26
|
} catch {
|
|
25
|
-
console.error(
|
|
27
|
+
console.error(red('\n gh CLI is required but not installed.\n'))
|
|
26
28
|
console.error(' Install it with one of:')
|
|
27
|
-
console.error(
|
|
28
|
-
console.error(
|
|
29
|
-
console.error(
|
|
30
|
-
console.error(
|
|
29
|
+
console.error(cyan(' brew install gh ') + dim('# macOS'))
|
|
30
|
+
console.error(cyan(' sudo apt install gh ') + dim('# Debian/Ubuntu'))
|
|
31
|
+
console.error(cyan(' winget install GitHub.cli ') + dim('# Windows'))
|
|
32
|
+
console.error(cyan(' conda install -c conda-forge gh ') + dim('# conda'))
|
|
31
33
|
console.error()
|
|
32
|
-
console.error(
|
|
34
|
+
console.error(dim(' More: https://cli.github.com'))
|
|
33
35
|
console.error()
|
|
34
36
|
process.exit(1)
|
|
35
37
|
}
|
|
@@ -62,9 +64,9 @@ export function requireAuth() {
|
|
|
62
64
|
const token = getToken()
|
|
63
65
|
if (token) return token
|
|
64
66
|
|
|
65
|
-
console.error(
|
|
67
|
+
console.error(red('\n Not authenticated with GitHub.\n'))
|
|
66
68
|
console.error(' Run:')
|
|
67
|
-
console.error(
|
|
69
|
+
console.error(cyan(' gh auth login'))
|
|
68
70
|
console.error()
|
|
69
71
|
process.exit(1)
|
|
70
72
|
}
|
|
@@ -75,11 +77,26 @@ export function requireAuth() {
|
|
|
75
77
|
*/
|
|
76
78
|
export function getAuthStatus() {
|
|
77
79
|
try {
|
|
78
|
-
const raw = execFileSync('gh', ['auth', 'status', '--json'], {
|
|
80
|
+
const raw = execFileSync('gh', ['auth', 'status', '--json', 'hosts'], {
|
|
79
81
|
encoding: 'utf8',
|
|
80
82
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
81
83
|
}).trim()
|
|
82
|
-
|
|
84
|
+
const data = JSON.parse(raw)
|
|
85
|
+
// Normalize: flatten hosts map into an accounts array for callers
|
|
86
|
+
const accounts = []
|
|
87
|
+
if (data.hosts) {
|
|
88
|
+
for (const entries of Object.values(data.hosts)) {
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
accounts.push({
|
|
91
|
+
login: entry.login,
|
|
92
|
+
active: !!entry.active,
|
|
93
|
+
host: entry.host,
|
|
94
|
+
state: entry.state,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { accounts }
|
|
83
100
|
} catch {
|
|
84
101
|
// Older gh versions may not support --json, fall back to text parsing
|
|
85
102
|
try {
|
|
@@ -217,6 +234,32 @@ export function createIssue(repo, { title, body, labels }) {
|
|
|
217
234
|
return { number, url }
|
|
218
235
|
}
|
|
219
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Verify that a GitHub issue exists and is open.
|
|
239
|
+
* Used after `createIssue()` to confirm the issue was actually created
|
|
240
|
+
* before removing it from the outbox.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} repo - owner/name
|
|
243
|
+
* @param {number} number - issue number
|
|
244
|
+
* @returns {boolean} true if the issue exists and is open
|
|
245
|
+
*/
|
|
246
|
+
export function verifyIssue(repo, number) {
|
|
247
|
+
try {
|
|
248
|
+
const raw = execFileSync('gh', [
|
|
249
|
+
'issue', 'view', String(number),
|
|
250
|
+
'--repo', repo,
|
|
251
|
+
'--json', 'number,state',
|
|
252
|
+
], {
|
|
253
|
+
encoding: 'utf8',
|
|
254
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
255
|
+
}).trim()
|
|
256
|
+
const data = JSON.parse(raw)
|
|
257
|
+
return data.state === 'OPEN'
|
|
258
|
+
} catch {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
220
263
|
/**
|
|
221
264
|
* List all label names for a repo.
|
|
222
265
|
* @param {string} repo - owner/name
|
|
@@ -278,16 +321,22 @@ export function addLabelsToIssue(repo, issueNumber, labels) {
|
|
|
278
321
|
*/
|
|
279
322
|
export function listIssues(repo, opts = {}) {
|
|
280
323
|
const limit = opts.limit ?? 100
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
'
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
324
|
+
let raw
|
|
325
|
+
try {
|
|
326
|
+
raw = execFileSync('gh', [
|
|
327
|
+
'issue', 'list',
|
|
328
|
+
'--repo', repo,
|
|
329
|
+
'--state', 'open',
|
|
330
|
+
'--limit', String(limit),
|
|
331
|
+
'--json', 'number,title,url,labels,createdAt',
|
|
332
|
+
], {
|
|
333
|
+
encoding: 'utf8',
|
|
334
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
335
|
+
}).trim()
|
|
336
|
+
} catch (err) {
|
|
337
|
+
const reason = (err.stderr || err.message || '').trim()
|
|
338
|
+
throw new Error(`gh issue list failed: ${reason}`)
|
|
339
|
+
}
|
|
291
340
|
|
|
292
341
|
if (!raw) return []
|
|
293
342
|
const issues = JSON.parse(raw)
|
|
@@ -300,6 +349,38 @@ export function listIssues(repo, opts = {}) {
|
|
|
300
349
|
}))
|
|
301
350
|
}
|
|
302
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Upload an image file to a repo's .tissues/images/ directory and return
|
|
354
|
+
* the raw URL for embedding in markdown.
|
|
355
|
+
*
|
|
356
|
+
* Uses the GitHub Contents API via `gh api` to commit the file.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} repo - owner/name
|
|
359
|
+
* @param {string} localPath - path to the local image file
|
|
360
|
+
* @param {string} [filename] - optional filename override
|
|
361
|
+
* @returns {{ url: string, path: string }} raw URL and repo path
|
|
362
|
+
*/
|
|
363
|
+
export function uploadImageToRepo(repo, localPath, filename) {
|
|
364
|
+
const name = filename || nodePath.basename(localPath)
|
|
365
|
+
const repoPath = `.tissues/images/${name}`
|
|
366
|
+
const content = fs.readFileSync(localPath).toString('base64')
|
|
367
|
+
|
|
368
|
+
const raw = execFileSync('gh', [
|
|
369
|
+
'api',
|
|
370
|
+
`repos/${repo}/contents/${repoPath}`,
|
|
371
|
+
'--method', 'PUT',
|
|
372
|
+
'-f', `message=tissues: attach ${name}`,
|
|
373
|
+
'-f', `content=${content}`,
|
|
374
|
+
], {
|
|
375
|
+
encoding: 'utf8',
|
|
376
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
377
|
+
}).trim()
|
|
378
|
+
|
|
379
|
+
const data = JSON.parse(raw)
|
|
380
|
+
const url = data.content?.download_url || `https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}`
|
|
381
|
+
return { url, path: repoPath }
|
|
382
|
+
}
|
|
383
|
+
|
|
303
384
|
/**
|
|
304
385
|
* List repos the user has access to.
|
|
305
386
|
* @param {{ limit?: number }} [opts]
|
package/src/lib/repo-picker.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { search } from '@inquirer/prompts'
|
|
2
2
|
import { store, setConfig } from './config.js'
|
|
3
|
+
import { theme } from './theme.js'
|
|
3
4
|
import { listRepos } from './gh.js'
|
|
4
5
|
import ora from 'ora'
|
|
5
6
|
|
|
@@ -46,6 +47,7 @@ export async function pickRepo() {
|
|
|
46
47
|
.filter((r) => r.toLowerCase().includes(term))
|
|
47
48
|
.map((r) => ({ name: r, value: r }))
|
|
48
49
|
},
|
|
50
|
+
theme,
|
|
49
51
|
})
|
|
50
52
|
|
|
51
53
|
trackRepoUsage(repo)
|
package/src/lib/safety.js
CHANGED
|
@@ -110,7 +110,7 @@ export function checkSafety(repo, agent, config = {}) {
|
|
|
110
110
|
|
|
111
111
|
// Step 3: half-open → log and allow the probe through (no rate checks)
|
|
112
112
|
if (circuitState === 'half-open') {
|
|
113
|
-
|
|
113
|
+
// half-open: silently allow the probe through — no user-facing noise
|
|
114
114
|
return {
|
|
115
115
|
allowed: true,
|
|
116
116
|
circuitState,
|
package/src/lib/templates.js
CHANGED
|
@@ -20,18 +20,14 @@ const BUILT_IN_TEMPLATES = {
|
|
|
20
20
|
name: 'Feature Request',
|
|
21
21
|
body: `## Feature\n\n{{description}}\n\n## Motivation\n\n## Proposed solution\n\n## Alternatives considered\n\n`,
|
|
22
22
|
},
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
refactor: {
|
|
32
|
-
name: 'Refactor',
|
|
33
|
-
body: `## Refactor\n\n{{description}}\n\n## Motivation\n\n## Scope\n\n## Risk assessment\n\n`,
|
|
34
|
-
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Return the list of built-in template keys.
|
|
27
|
+
* @returns {string[]}
|
|
28
|
+
*/
|
|
29
|
+
export function builtInTemplateKeys() {
|
|
30
|
+
return Object.keys(BUILT_IN_TEMPLATES)
|
|
35
31
|
}
|
|
36
32
|
|
|
37
33
|
// ---------------------------------------------------------------------------
|
package/src/lib/theme.js
ADDED
package/src/commands/use.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
2
|
-
import { pickRepo } from '../lib/repo-picker.js'
|
|
3
|
-
import { setConfig } from '../lib/config.js'
|
|
4
|
-
import chalk from 'chalk'
|
|
5
|
-
|
|
6
|
-
export const useCommand = new Command('use')
|
|
7
|
-
.description('Set the active repository context')
|
|
8
|
-
.argument('[repo]', 'Repository in owner/name format (e.g. owner/repo)')
|
|
9
|
-
.option('--repo <repo>', 'Repository in owner/name format (alias for positional argument)')
|
|
10
|
-
.action(async (repoArg, opts) => {
|
|
11
|
-
const repo = repoArg || opts.repo
|
|
12
|
-
if (repo) {
|
|
13
|
-
setConfig({ activeRepo: repo })
|
|
14
|
-
console.log(chalk.green(`✓ Active repo set to ${repo}`))
|
|
15
|
-
} else {
|
|
16
|
-
const selected = await pickRepo()
|
|
17
|
-
console.log(chalk.green(`\n✓ Active repo set to ${selected}`))
|
|
18
|
-
}
|
|
19
|
-
})
|