tissues 0.4.1 โ†’ 0.5.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # tissues
1
+ # tissues ๐Ÿงป
2
2
 
3
3
  AI-enhanced GitHub issue creation with built-in safety guardrails.
4
4
 
@@ -192,12 +192,11 @@ Every issue created by tissues includes a machine-readable `<!-- tissues-meta --
192
192
 
193
193
  ## Configuration
194
194
 
195
- Configuration is loaded and merged from four sources in ascending priority order:
195
+ Configuration is loaded and merged from three sources in ascending priority order:
196
196
 
197
197
  1. Built-in defaults
198
198
  2. User-level config: `~/.config/tissues/config.json`
199
199
  3. Repo-level config: `.tissues/config.json`
200
- 4. Environment variables: `TISSUES_*`
201
200
 
202
201
  ### Example `.tissues/config.json`
203
202
 
@@ -230,15 +229,20 @@ Configuration is loaded and merged from four sources in ascending priority order
230
229
  }
231
230
  ```
232
231
 
233
- ### Environment Variables
232
+ ### `tissues config`
234
233
 
235
- Environment variables follow the pattern `TISSUES_<SECTION>_<KEY>`:
234
+ Get or set persistent user-level config values using dot notation. Values are stored in `~/.config/tissues/config.json`.
236
235
 
237
236
  ```bash
238
- TISSUES_SAFETY_MAX_PER_HOUR=5
239
- TISSUES_SAFETY_BURST_LIMIT=3
240
- TISSUES_SAFETY_GLOBAL_MAX_PER_HOUR=20
241
- TISSUES_ATTRIBUTION_DEFAULT_AGENT=my-agent
237
+ # Set a value
238
+ tissues config set safety.maxPerHour 5
239
+ tissues config set attribution.defaultAgent my-agent
240
+
241
+ # Read a value
242
+ tissues config get safety.maxPerHour
243
+
244
+ # Show all resolved config
245
+ tissues config list
242
246
  ```
243
247
 
244
248
  ### Hooks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tissues",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "AI-enhanced GitHub issue creation with built-in safety guardrails. Wraps gh CLI with circuit breakers, rate limiting, dedup, and templates.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@ import { Command } from 'commander'
2
2
  import { createRequire } from 'module'
3
3
  const { version } = createRequire(import.meta.url)('../package.json')
4
4
  import { authCommand } from './commands/auth.js'
5
+ import { configCommand } from './commands/config.js'
5
6
  import { openCommand } from './commands/open.js'
6
7
  import { createCommand } from './commands/create.js'
7
8
  import { listCommand } from './commands/list.js'
@@ -61,6 +62,7 @@ program.hook('preAction', () => {
61
62
  authCommand.description(authDescription())
62
63
  })
63
64
  program.addCommand(authCommand)
65
+ program.addCommand(configCommand)
64
66
  program.addCommand(openCommand)
65
67
  program.addCommand(createCommand)
66
68
  program.addCommand(listCommand)
@@ -0,0 +1,104 @@
1
+ import { Command } from 'commander'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import chalk from 'chalk'
5
+ import { loadConfig, userConfigPath, BUILT_IN_DEFAULTS } from '../lib/defaults.js'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function readUserConfig() {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(userConfigPath(), 'utf8'))
14
+ } catch {
15
+ return {}
16
+ }
17
+ }
18
+
19
+ function writeUserConfig(obj) {
20
+ const filePath = userConfigPath()
21
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
22
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', 'utf8')
23
+ }
24
+
25
+ function getNestedValue(obj, dotKey) {
26
+ return dotKey.split('.').reduce((o, k) => (o != null ? o[k] : undefined), obj)
27
+ }
28
+
29
+ function setNestedValue(obj, dotKey, value) {
30
+ const parts = dotKey.split('.')
31
+ const result = { ...obj }
32
+ let cursor = result
33
+ for (let i = 0; i < parts.length - 1; i++) {
34
+ const k = parts[i]
35
+ cursor[k] = cursor[k] && typeof cursor[k] === 'object' ? { ...cursor[k] } : {}
36
+ cursor = cursor[k]
37
+ }
38
+ cursor[parts[parts.length - 1]] = value
39
+ return result
40
+ }
41
+
42
+ function coerceValue(dotKey, raw) {
43
+ const parts = dotKey.split('.')
44
+ if (parts.length !== 2) return raw
45
+ const [section, field] = parts
46
+ const defaultVal = BUILT_IN_DEFAULTS[section]?.[field]
47
+ if (typeof defaultVal === 'number') {
48
+ const n = Number(raw)
49
+ if (isNaN(n)) throw new Error(`Expected a number for ${dotKey}, got: ${raw}`)
50
+ return n
51
+ }
52
+ if (typeof defaultVal === 'boolean') {
53
+ if (raw === 'true' || raw === '1') return true
54
+ if (raw === 'false' || raw === '0') return false
55
+ throw new Error(`Expected true/false for ${dotKey}, got: ${raw}`)
56
+ }
57
+ if (raw === 'null') return null
58
+ return raw
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Command
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export const configCommand = new Command('config')
66
+ .description('Get or set persistent configuration values')
67
+
68
+ configCommand
69
+ .command('get <key>')
70
+ .description('Get a config value (dot notation, e.g. safety.maxPerHour)')
71
+ .action((key) => {
72
+ const cfg = loadConfig()
73
+ const value = getNestedValue(cfg, key)
74
+ if (value === undefined) {
75
+ console.error(chalk.red(`Unknown config key: ${key}`))
76
+ process.exit(1)
77
+ }
78
+ console.log(value)
79
+ })
80
+
81
+ configCommand
82
+ .command('set <key> <value>')
83
+ .description('Set a config value (dot notation, e.g. safety.maxPerHour 5)')
84
+ .action((key, rawValue) => {
85
+ let value
86
+ try {
87
+ value = coerceValue(key, rawValue)
88
+ } catch (err) {
89
+ console.error(chalk.red(err.message))
90
+ process.exit(1)
91
+ }
92
+ const current = readUserConfig()
93
+ const updated = setNestedValue(current, key, value)
94
+ writeUserConfig(updated)
95
+ console.log(chalk.green(`โœ” Set ${key} = ${value}`))
96
+ })
97
+
98
+ configCommand
99
+ .command('list')
100
+ .description('Show all resolved config values')
101
+ .action(() => {
102
+ const cfg = loadConfig()
103
+ console.log(JSON.stringify(cfg, null, 2))
104
+ })
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'node:child_process'
2
+ import fs from 'node:fs'
2
3
  import { Command } from 'commander'
3
4
  import { input, select, confirm } from '@inquirer/prompts'
4
5
  import { store } from '../lib/config.js'
@@ -9,7 +10,13 @@ import { listTemplates, loadTemplate, renderTemplate } from '../lib/templates.js
9
10
  import { renderAttribution } from '../lib/attribution.js'
10
11
  import { computeFingerprint } from '../lib/dedup.js'
11
12
  import { pickRepo } from '../lib/repo-picker.js'
12
- import { requireAuth, createIssue } from '../lib/gh.js'
13
+ import {
14
+ requireAuth,
15
+ createIssue,
16
+ listLabels,
17
+ createLabel,
18
+ addLabelsToIssue,
19
+ } from '../lib/gh.js'
13
20
  import chalk from 'chalk'
14
21
  import ora from 'ora'
15
22
 
@@ -93,181 +100,190 @@ async function enhanceWithAI(title, description, templateBody) {
93
100
  }
94
101
 
95
102
  // ---------------------------------------------------------------------------
96
- // Command
103
+ // AbortError โ€” thrown for user-initiated cancellations (exit 0)
97
104
  // ---------------------------------------------------------------------------
98
105
 
99
- export const createCommand = new Command('create')
100
- .description('Create a new GitHub issue')
101
- .option('--repo <repo>', 'Repository override (owner/name)')
102
- .option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
103
- .option('--agent <name>', 'Agent identifier for attribution (default: human)')
104
- .option('--session <id>', 'Session ID for attribution')
105
- .option('--title <title>', 'Issue title (skips interactive prompt)')
106
- .option('--body <body>', 'Issue body / description (skips interactive prompt)')
107
- .option('--labels <labels>', 'Comma-separated labels to apply')
108
- .option('--force', 'Skip dedup warnings (still blocks on exact matches)')
109
- .option('--dry-run', 'Check dedup and safety without creating the issue')
110
- .action(async (opts) => {
111
- // -----------------------------------------------------------------------
112
- // 0. Ensure authenticated
113
- // -----------------------------------------------------------------------
114
- requireAuth()
106
+ class AbortError extends Error {
107
+ constructor(msg) {
108
+ super(msg)
109
+ this.isAbort = true
110
+ }
111
+ }
115
112
 
116
- // -----------------------------------------------------------------------
117
- // 1. Resolve repo
118
- // -----------------------------------------------------------------------
119
- let repo = opts.repo
120
- if (!repo) {
121
- repo = store.get('activeRepo')
122
- }
123
- if (!repo) {
124
- console.log(chalk.yellow('No active repo. Pick one:\n'))
125
- repo = await pickRepo()
126
- console.log()
127
- }
113
+ // ---------------------------------------------------------------------------
114
+ // Core create logic (extracted so batch mode can reuse it)
115
+ // ---------------------------------------------------------------------------
128
116
 
129
- // -----------------------------------------------------------------------
130
- // 2. Load config
131
- // -----------------------------------------------------------------------
132
- const repoRoot = findRepoRoot()
133
- const config = loadConfig(repoRoot)
117
+ /**
118
+ * Run the full issue-creation flow.
119
+ *
120
+ * @param {object} opts - command options (same shape as Commander opts)
121
+ * @returns {Promise<{ url: string, number: number } | null>} null for dry-run
122
+ * @throws {AbortError} when the user cancels interactively
123
+ * @throws {Error} on hard failures
124
+ */
125
+ async function runCreate(opts) {
126
+ // -----------------------------------------------------------------------
127
+ // 0. Ensure authenticated
128
+ // -----------------------------------------------------------------------
129
+ requireAuth()
130
+
131
+ // -----------------------------------------------------------------------
132
+ // 1. Resolve repo
133
+ // -----------------------------------------------------------------------
134
+ let repo = opts.repo
135
+ if (!repo) {
136
+ repo = store.get('activeRepo')
137
+ }
138
+ if (!repo) {
139
+ console.log(chalk.yellow('No active repo. Pick one:\n'))
140
+ repo = await pickRepo()
141
+ console.log()
142
+ }
134
143
 
135
- // -----------------------------------------------------------------------
136
- // 3. Check safety
137
- // -----------------------------------------------------------------------
138
- const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
139
- const safetyResult = checkSafety(repo, agent, config.safety)
144
+ // -----------------------------------------------------------------------
145
+ // 2. Load config
146
+ // -----------------------------------------------------------------------
147
+ const repoRoot = findRepoRoot()
148
+ const config = loadConfig(repoRoot)
140
149
 
141
- warnIfCircuitNotClosed(safetyResult.circuitState)
150
+ // -----------------------------------------------------------------------
151
+ // 3. Check safety
152
+ // -----------------------------------------------------------------------
153
+ const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
154
+ const safetyResult = checkSafety(repo, agent, config.safety)
142
155
 
143
- if (!safetyResult.allowed) {
144
- recordFailure(repo, agent, config.safety)
145
- console.error(chalk.red('Safety check failed:'), safetyResult.reason)
146
- process.exit(1)
147
- }
156
+ warnIfCircuitNotClosed(safetyResult.circuitState)
148
157
 
149
- // -----------------------------------------------------------------------
150
- // 4. Get inputs
151
- // -----------------------------------------------------------------------
152
- const title = opts.title ?? (await input({ message: 'Issue title' }))
153
- if (!title || !title.trim()) {
154
- console.error(chalk.red('Title is required.'))
155
- process.exit(1)
156
- }
158
+ if (!safetyResult.allowed) {
159
+ recordFailure(repo, agent, config.safety)
160
+ console.error(chalk.red('Safety check failed:'), safetyResult.reason)
161
+ throw new Error('Safety check failed')
162
+ }
157
163
 
158
- const description =
159
- opts.body ??
160
- (await input({
161
- message: 'Brief description (optional, used for AI enhancement)',
162
- }))
164
+ // -----------------------------------------------------------------------
165
+ // 4. Get inputs
166
+ // -----------------------------------------------------------------------
167
+ const title = opts.title ?? (await input({ message: 'Issue title' }))
168
+ if (!title || !title.trim()) {
169
+ console.error(chalk.red('Title is required.'))
170
+ throw new Error('Title is required')
171
+ }
163
172
 
164
- // -----------------------------------------------------------------------
165
- // 5. Pick template
166
- // -----------------------------------------------------------------------
167
- let templateName = opts.template
173
+ const description =
174
+ opts.body ??
175
+ (await input({
176
+ message: 'Brief description (optional, used for AI enhancement)',
177
+ }))
168
178
 
169
- // Fall back to config default
170
- if (!templateName) {
171
- templateName = config.templates?.default ?? null
172
- }
179
+ // -----------------------------------------------------------------------
180
+ // 5. Pick template
181
+ // -----------------------------------------------------------------------
182
+ let templateName = opts.template
173
183
 
174
- // If still unresolved and interactive, let user pick
175
- if (!templateName) {
176
- const available = listTemplates(repoRoot)
177
- // Deduplicate by key (higher-priority sources shadow lower ones)
178
- const seen = new Set()
179
- const choices = []
180
- for (const tpl of available) {
181
- if (!seen.has(tpl.key)) {
182
- seen.add(tpl.key)
183
- choices.push({
184
- name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
185
- value: tpl.key,
186
- })
187
- }
188
- }
184
+ // Fall back to config default
185
+ if (!templateName) {
186
+ templateName = config.templates?.default ?? null
187
+ }
189
188
 
190
- if (choices.length > 0) {
191
- templateName = await select({
192
- message: 'Choose a template',
193
- choices,
189
+ // If still unresolved and interactive, let user pick
190
+ if (!templateName) {
191
+ const available = listTemplates(repoRoot)
192
+ // Deduplicate by key (higher-priority sources shadow lower ones)
193
+ const seen = new Set()
194
+ const choices = []
195
+ for (const tpl of available) {
196
+ if (!seen.has(tpl.key)) {
197
+ seen.add(tpl.key)
198
+ choices.push({
199
+ name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
200
+ value: tpl.key,
194
201
  })
195
- } else {
196
- templateName = 'default'
197
202
  }
198
203
  }
199
204
 
200
- let template
201
- try {
202
- template = loadTemplate(templateName, repoRoot)
203
- } catch (err) {
204
- console.error(chalk.red(`Template error: ${err.message}`))
205
- process.exit(1)
206
- }
207
-
208
- // -----------------------------------------------------------------------
209
- // 6. Check dedup
210
- // -----------------------------------------------------------------------
211
- const session = opts.session ?? null
212
- const dedupSpinner = ora('Checking for duplicates...').start()
213
- let dedupResult
214
- try {
215
- dedupResult = await checkDuplicate(repo, {
216
- title,
217
- body: description,
218
- agent,
219
- // No idempotency key by default โ€” callers who need deterministic keys
220
- // can pass --session and combine with agent + title in future work
205
+ if (choices.length > 0) {
206
+ templateName = await select({
207
+ message: 'Choose a template',
208
+ choices,
221
209
  })
222
- dedupSpinner.stop()
223
- } catch (err) {
224
- dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
225
- dedupResult = { action: 'allow', results: [] }
210
+ } else {
211
+ templateName = 'default'
226
212
  }
213
+ }
227
214
 
228
- if (dedupResult.action === 'block') {
229
- recordFailure(repo, agent, config.safety)
230
- console.error(chalk.red('Duplicate detected โ€” issue not created.'))
231
- for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
232
- console.error(formatDedupResult(r))
233
- }
234
- process.exit(1)
235
- }
215
+ let template
216
+ try {
217
+ template = loadTemplate(templateName, repoRoot)
218
+ } catch (err) {
219
+ console.error(chalk.red(`Template error: ${err.message}`))
220
+ throw new Error(`Template error: ${err.message}`)
221
+ }
236
222
 
237
- if (dedupResult.action === 'warn' && !opts.force) {
238
- console.warn(chalk.yellow('\nSimilar issue(s) found:'))
239
- for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
240
- console.warn(formatDedupResult(r))
241
- }
242
- console.warn()
223
+ // -----------------------------------------------------------------------
224
+ // 6. Check dedup
225
+ // -----------------------------------------------------------------------
226
+ const session = opts.session ?? null
227
+ const dedupSpinner = ora('Checking for duplicates...').start()
228
+ let dedupResult
229
+ try {
230
+ dedupResult = await checkDuplicate(repo, {
231
+ title,
232
+ body: description,
233
+ agent,
234
+ // No idempotency key by default โ€” callers who need deterministic keys
235
+ // can pass --session and combine with agent + title in future work
236
+ })
237
+ dedupSpinner.stop()
238
+ } catch (err) {
239
+ dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
240
+ dedupResult = { action: 'allow', results: [] }
241
+ }
243
242
 
244
- const proceed = await confirm({
245
- message: 'Create anyway?',
246
- default: false,
247
- })
243
+ if (dedupResult.action === 'block') {
244
+ recordFailure(repo, agent, config.safety)
245
+ console.error(chalk.red('Duplicate detected โ€” issue not created.'))
246
+ for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
247
+ console.error(formatDedupResult(r))
248
+ }
249
+ throw new Error('Duplicate detected')
250
+ }
248
251
 
249
- if (!proceed) {
250
- console.log(chalk.dim('Aborted.'))
251
- process.exit(0)
252
- }
252
+ if (dedupResult.action === 'warn' && !opts.force) {
253
+ console.warn(chalk.yellow('\nSimilar issue(s) found:'))
254
+ for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
255
+ console.warn(formatDedupResult(r))
253
256
  }
257
+ console.warn()
254
258
 
255
- // -----------------------------------------------------------------------
256
- // 7. Render template
257
- // -----------------------------------------------------------------------
258
- const renderedTemplate = renderTemplate(template.body, {
259
- title,
260
- description: description || '',
261
- agent,
262
- session: session || '',
263
- date: new Date().toISOString().slice(0, 10),
264
- repo,
259
+ const proceed = await confirm({
260
+ message: 'Create anyway?',
261
+ default: false,
265
262
  })
266
263
 
267
- // -----------------------------------------------------------------------
268
- // 8. AI enhancement (stub โ€” passes rendered template as context)
269
- // -----------------------------------------------------------------------
270
- let body = renderedTemplate
264
+ if (!proceed) {
265
+ console.log(chalk.dim('Aborted.'))
266
+ throw new AbortError('Aborted by user')
267
+ }
268
+ }
269
+
270
+ // -----------------------------------------------------------------------
271
+ // 7. Render template
272
+ // -----------------------------------------------------------------------
273
+ const renderedTemplate = renderTemplate(template.body, {
274
+ title,
275
+ description: description || '',
276
+ agent,
277
+ session: session || '',
278
+ date: new Date().toISOString().slice(0, 10),
279
+ repo,
280
+ })
281
+
282
+ // -----------------------------------------------------------------------
283
+ // 8. AI enhancement (skip for --no-enhance or --dry-run)
284
+ // -----------------------------------------------------------------------
285
+ let body = renderedTemplate
286
+ if (opts.enhance !== false && !opts.dryRun) {
271
287
  const aiSpinner = ora('Enhancing with AI...').start()
272
288
  try {
273
289
  body = await enhanceWithAI(title, description, renderedTemplate)
@@ -275,86 +291,207 @@ export const createCommand = new Command('create')
275
291
  } catch {
276
292
  aiSpinner.warn('AI enhancement unavailable โ€” using template as-is')
277
293
  }
294
+ }
278
295
 
279
- // -----------------------------------------------------------------------
280
- // 9. Add attribution
281
- // -----------------------------------------------------------------------
282
- const fingerprint = computeFingerprint(title, body)
283
- const attribution = renderAttribution({
296
+ // -----------------------------------------------------------------------
297
+ // 9. Add attribution
298
+ // -----------------------------------------------------------------------
299
+ const fingerprint = computeFingerprint(title, body)
300
+ const attributionBlock = renderAttribution({
301
+ agent,
302
+ session: session ?? undefined,
303
+ trigger: 'cli-create',
304
+ fingerprint: `sha256:${fingerprint}`,
305
+ })
306
+
307
+ body = body.trimEnd() + '\n\n' + attributionBlock
308
+
309
+ // -----------------------------------------------------------------------
310
+ // 10. Parse labels
311
+ // -----------------------------------------------------------------------
312
+ const labels = opts.labels
313
+ ? opts.labels
314
+ .split(',')
315
+ .map((l) => l.trim())
316
+ .filter(Boolean)
317
+ : []
318
+
319
+ // -----------------------------------------------------------------------
320
+ // Dry run โ€” stop here
321
+ // -----------------------------------------------------------------------
322
+ if (opts.dryRun) {
323
+ const border = 'โ”€'.repeat(44)
324
+ console.log(chalk.cyan(`\nโ”€โ”€โ”€ DRY RUN ${border.slice(11)}`))
325
+ console.log(` ${chalk.bold('Repo: ')} ${repo}`)
326
+ console.log(` ${chalk.bold('Title: ')} ${title}`)
327
+ console.log(` ${chalk.bold('Agent: ')} ${agent}`)
328
+ console.log(` ${chalk.bold('Template:')} ${template.name} (${template.source})`)
329
+ if (labels.length > 0) console.log(` ${chalk.bold('Labels: ')} ${labels.join(', ')}`)
330
+ console.log(`\n ${chalk.bold('Body:')}`)
331
+ console.log(renderedTemplate)
332
+ console.log(`\n ${chalk.dim('โ”€โ”€โ”€ attribution (invisible on GitHub) โ”€โ”€โ”€')}`)
333
+ console.log(chalk.dim(attributionBlock))
334
+ console.log(chalk.cyan(`${border}\n No issue created.\n`))
335
+ return null
336
+ }
337
+
338
+ // -----------------------------------------------------------------------
339
+ // 11. Create issue (with graceful label handling)
340
+ // -----------------------------------------------------------------------
341
+ let existingLabels = labels
342
+ let missingLabels = []
343
+
344
+ if (labels.length > 0) {
345
+ try {
346
+ const repoLabelNames = listLabels(repo)
347
+ existingLabels = labels.filter((l) => repoLabelNames.includes(l))
348
+ missingLabels = labels.filter((l) => !repoLabelNames.includes(l))
349
+ } catch {
350
+ // If listLabels fails, proceed with all labels (original behavior)
351
+ }
352
+ }
353
+
354
+ const createSpinner = ora('Creating issue...').start()
355
+ let issue
356
+ try {
357
+ issue = await createIssue(repo, { title, body, labels: existingLabels })
358
+ createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
359
+ } catch (err) {
360
+ // GitHub API failures are NOT safety failures โ€” do not record to circuit
361
+ createSpinner.fail(`Failed to create issue: ${err.message}`)
362
+ throw new Error(`Failed to create issue: ${err.message}`)
363
+ }
364
+
365
+ if (missingLabels.length > 0) {
366
+ console.log(
367
+ chalk.yellow(`Labels not found: ${missingLabels.join(', ')} โ€” issue created without them`),
368
+ )
369
+ const createMissing = await confirm({
370
+ message: 'Create missing labels and apply them?',
371
+ default: true,
372
+ })
373
+ if (createMissing) {
374
+ for (const labelName of missingLabels) {
375
+ const labelSpinner = ora(`Creating label "${labelName}"...`).start()
376
+ try {
377
+ createLabel(repo, labelName)
378
+ labelSpinner.succeed(`Label created: ${labelName}`)
379
+ } catch (err) {
380
+ labelSpinner.warn(`Failed to create label "${labelName}": ${err.message}`)
381
+ }
382
+ }
383
+ try {
384
+ addLabelsToIssue(repo, issue.number, missingLabels)
385
+ console.log(chalk.green('โœ“ Labels created and applied'))
386
+ } catch (err) {
387
+ console.warn(chalk.yellow(`Failed to apply labels: ${err.message}`))
388
+ }
389
+ }
390
+ }
391
+
392
+ // -----------------------------------------------------------------------
393
+ // 12. Record success
394
+ // -----------------------------------------------------------------------
395
+ try {
396
+ await recordCreation(repo, {
397
+ title,
398
+ body,
399
+ issueNumber: issue.number,
284
400
  agent,
285
- session: session ?? undefined,
286
- trigger: 'cli-create',
287
- fingerprint: `sha256:${fingerprint}`,
288
401
  })
402
+ } catch (err) {
403
+ // Non-fatal โ€” dedup DB write failure shouldn't abort the command
404
+ console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
405
+ }
289
406
 
290
- body = body.trimEnd() + '\n\n' + attribution
407
+ recordSuccess(repo, agent)
291
408
 
292
- // -----------------------------------------------------------------------
293
- // 10. Parse labels
294
- // -----------------------------------------------------------------------
295
- const labels = opts.labels
296
- ? opts.labels
297
- .split(',')
298
- .map((l) => l.trim())
299
- .filter(Boolean)
300
- : []
409
+ // -----------------------------------------------------------------------
410
+ // Run postCreate hook if configured
411
+ // -----------------------------------------------------------------------
412
+ const hookCmd = config.hooks?.postCreate
413
+ if (hookCmd) {
414
+ runPostCreateHook(hookCmd, {
415
+ repo,
416
+ issueNumber: issue.number,
417
+ issueUrl: issue.url,
418
+ })
419
+ }
301
420
 
302
- // -----------------------------------------------------------------------
303
- // Dry run โ€” stop here
304
- // -----------------------------------------------------------------------
305
- if (opts.dryRun) {
306
- console.log(chalk.cyan('\n--- DRY RUN ---'))
307
- console.log(chalk.bold('Repo: '), repo)
308
- console.log(chalk.bold('Title: '), title)
309
- console.log(chalk.bold('Agent: '), agent)
310
- console.log(chalk.bold('Template:'), `${template.name} (${template.source})`)
311
- if (labels.length > 0) console.log(chalk.bold('Labels: '), labels.join(', '))
312
- console.log(chalk.bold('\nBody preview:'))
313
- console.log(chalk.dim(body))
314
- console.log(chalk.cyan('--- END DRY RUN (no issue created) ---\n'))
315
- process.exit(0)
316
- }
421
+ return { url: issue.url, number: issue.number }
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Command
426
+ // ---------------------------------------------------------------------------
317
427
 
428
+ export const createCommand = new Command('create')
429
+ .description('Create a new GitHub issue')
430
+ .option('--repo <repo>', 'Repository override (owner/name)')
431
+ .option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
432
+ .option('--agent <name>', 'Agent identifier for attribution (default: human)')
433
+ .option('--session <id>', 'Session ID for attribution')
434
+ .option('--title <title>', 'Issue title (skips interactive prompt)')
435
+ .option('--body <body>', 'Issue body / description (skips interactive prompt)')
436
+ .option('--labels <labels>', 'Comma-separated labels to apply')
437
+ .option('--force', 'Skip dedup warnings (still blocks on exact matches)')
438
+ .option('--dry-run', 'Check dedup and safety without creating the issue')
439
+ .option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
440
+ .option(
441
+ '--batch <file>',
442
+ 'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
443
+ )
444
+ .action(async (opts) => {
318
445
  // -----------------------------------------------------------------------
319
- // 11. Create issue
446
+ // Batch mode
320
447
  // -----------------------------------------------------------------------
321
- const createSpinner = ora('Creating issue...').start()
322
- let issue
323
- try {
324
- issue = await createIssue(repo, { title, body, labels })
325
- createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
326
- } catch (err) {
327
- // GitHub API failures are NOT safety failures โ€” do not record to circuit
328
- createSpinner.fail(`Failed to create issue: ${err.message}`)
329
- process.exit(1)
448
+ if (opts.batch) {
449
+ let items
450
+ try {
451
+ items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
452
+ } catch (err) {
453
+ console.error(chalk.red(`Failed to read batch file: ${err.message}`))
454
+ process.exit(1)
455
+ }
456
+
457
+ if (!Array.isArray(items)) {
458
+ console.error(chalk.red('Batch file must contain a JSON array'))
459
+ process.exit(1)
460
+ }
461
+
462
+ const results = []
463
+ for (const [i, item] of items.entries()) {
464
+ console.log(chalk.dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
465
+ // item fields override CLI opts; labels array โ†’ comma-string for runCreate
466
+ const itemOpts = {
467
+ ...opts,
468
+ ...item,
469
+ labels: item.labels?.join(',') ?? opts.labels,
470
+ }
471
+ try {
472
+ const result = await runCreate(itemOpts)
473
+ results.push({ title: item.title, url: result?.url, ok: true })
474
+ } catch (err) {
475
+ results.push({ title: item.title, error: err.message, ok: false })
476
+ }
477
+ }
478
+
479
+ console.log(chalk.bold('\nBatch summary:'))
480
+ for (const r of results) {
481
+ if (r.ok) console.log(chalk.green(` โœ” ${r.title}`) + chalk.dim(` โ€” ${r.url}`))
482
+ else console.log(chalk.red(` โœ– ${r.title}`) + chalk.dim(` โ€” ${r.error}`))
483
+ }
484
+ process.exit(results.every((r) => r.ok) ? 0 : 1)
485
+ return
330
486
  }
331
487
 
332
488
  // -----------------------------------------------------------------------
333
- // 12. Record success
489
+ // Single mode
334
490
  // -----------------------------------------------------------------------
335
491
  try {
336
- await recordCreation(repo, {
337
- title,
338
- body,
339
- issueNumber: issue.number,
340
- agent,
341
- })
492
+ const result = await runCreate(opts)
493
+ if (result === null) process.exit(0) // dry-run
342
494
  } catch (err) {
343
- // Non-fatal โ€” dedup DB write failure shouldn't abort the command
344
- console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
345
- }
346
-
347
- recordSuccess(repo, agent)
348
-
349
- // -----------------------------------------------------------------------
350
- // Run postCreate hook if configured
351
- // -----------------------------------------------------------------------
352
- const hookCmd = config.hooks?.postCreate
353
- if (hookCmd) {
354
- runPostCreateHook(hookCmd, {
355
- repo,
356
- issueNumber: issue.number,
357
- issueUrl: issue.url,
358
- })
495
+ process.exit(err.isAbort ? 0 : 1)
359
496
  }
360
497
  })
@@ -6,7 +6,9 @@ import chalk from 'chalk'
6
6
  export const openCommand = new Command('open')
7
7
  .description('Set the active repository context')
8
8
  .argument('[repo]', 'Repository in owner/name format (e.g. owner/repo)')
9
- .action(async (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
10
12
  if (repo) {
11
13
  setConfig({ activeRepo: repo })
12
14
  console.log(chalk.green(`โœ“ Active repo set to ${repo}`))
@@ -101,7 +101,7 @@ export function buildAttribution(opts = {}) {
101
101
  if (model != null) meta.model = String(model)
102
102
 
103
103
  // Process info
104
- meta.pid = pid != null ? Number(pid) : process.pid
104
+ if (pid != null) meta.pid = Number(pid)
105
105
  meta.trigger = trigger != null ? String(trigger) : 'cli-create'
106
106
 
107
107
  // Deduplication handles
@@ -6,7 +6,7 @@ import os from 'node:os'
6
6
  // Built-in defaults
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const BUILT_IN_DEFAULTS = {
9
+ export const BUILT_IN_DEFAULTS = {
10
10
  // Safety
11
11
  safety: {
12
12
  maxPerHour: 10,
@@ -50,12 +50,6 @@ const BUILT_IN_DEFAULTS = {
50
50
  },
51
51
  }
52
52
 
53
- // ---------------------------------------------------------------------------
54
- // Env var prefix
55
- // ---------------------------------------------------------------------------
56
-
57
- const ENV_PREFIX = 'TISSUES_'
58
-
59
53
  // ---------------------------------------------------------------------------
60
54
  // Helpers
61
55
  // ---------------------------------------------------------------------------
@@ -113,64 +107,10 @@ function readJsonFile(filePath) {
113
107
  *
114
108
  * @returns {string}
115
109
  */
116
- function userConfigPath() {
110
+ export function userConfigPath() {
117
111
  return path.join(os.homedir(), '.config', 'tissues', 'config.json')
118
112
  }
119
113
 
120
- /**
121
- * Convert a flat `TISSUES_SECTION_KEY=value` environment variable map into
122
- * a nested object matching the config shape.
123
- *
124
- * Variable names are lowercased and split on `_` to build the path:
125
- * TISSUES_SAFETY_MAX_PER_HOUR=20 โ†’ { safety: { maxPerHour: 20 } }
126
- *
127
- * Simple camelCase reconstruction: after stripping the prefix and splitting on
128
- * `_`, the first segment is the section; the remaining segments are joined in
129
- * camelCase.
130
- *
131
- * Only variables whose section exists in BUILT_IN_DEFAULTS are included so we
132
- * do not accidentally pollute the config with unrelated env vars.
133
- *
134
- * @returns {object}
135
- */
136
- function configFromEnv() {
137
- const result = {}
138
- const knownSections = new Set(Object.keys(BUILT_IN_DEFAULTS))
139
-
140
- for (const [rawKey, rawValue] of Object.entries(process.env)) {
141
- if (!rawKey.startsWith(ENV_PREFIX)) continue
142
- const stripped = rawKey.slice(ENV_PREFIX.length).toLowerCase()
143
- const parts = stripped.split('_')
144
- if (parts.length < 2) continue
145
-
146
- const section = parts[0]
147
- if (!knownSections.has(section)) continue
148
-
149
- // Join remaining parts as camelCase
150
- const remainingParts = parts.slice(1)
151
- const fieldName = remainingParts
152
- .map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)))
153
- .join('')
154
-
155
- // Coerce value type based on built-in defaults
156
- let value = rawValue
157
- const builtInSection = BUILT_IN_DEFAULTS[section]
158
- if (builtInSection && fieldName in builtInSection) {
159
- const defaultVal = builtInSection[fieldName]
160
- if (typeof defaultVal === 'number') {
161
- value = Number(rawValue)
162
- } else if (typeof defaultVal === 'boolean') {
163
- value = rawValue === 'true' || rawValue === '1'
164
- }
165
- }
166
-
167
- if (!result[section]) result[section] = {}
168
- result[section][fieldName] = value
169
- }
170
-
171
- return result
172
- }
173
-
174
114
  // ---------------------------------------------------------------------------
175
115
  // Public API
176
116
  // ---------------------------------------------------------------------------
@@ -200,8 +140,7 @@ export function findRepoRoot(startDir) {
200
140
  * 1. Built-in defaults (lowest)
201
141
  * 2. User-level config (~/.config/tissues/config.json)
202
142
  * 3. Repo-level config (<repoRoot>/.tissues/config.json)
203
- * 4. Environment vars (TISSUES_*)
204
- * 5. CLI flags (passed as `cliOverrides`, highest)
143
+ * 4. CLI flags (passed as `cliOverrides`, highest)
205
144
  *
206
145
  * @param {string} [repoRoot] - path to the repo root; auto-detected if omitted
207
146
  * @param {object} [cliOverrides] - values from parsed CLI flags (already nested)
@@ -223,11 +162,7 @@ export function loadConfig(repoRoot, cliOverrides) {
223
162
  if (repoCfg) merged = deepMerge(merged, repoCfg)
224
163
  }
225
164
 
226
- // 4. Environment variables
227
- const envCfg = configFromEnv()
228
- if (Object.keys(envCfg).length > 0) merged = deepMerge(merged, envCfg)
229
-
230
- // 5. CLI overrides
165
+ // 4. CLI overrides
231
166
  if (cliOverrides && typeof cliOverrides === 'object') {
232
167
  merged = deepMerge(merged, cliOverrides)
233
168
  }
package/src/lib/gh.js CHANGED
@@ -44,10 +44,6 @@ export function requireGh() {
44
44
  * @returns {string | null}
45
45
  */
46
46
  export function getToken() {
47
- // Env vars take priority (CI/CD)
48
- const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
49
- if (envToken) return envToken
50
-
51
47
  try {
52
48
  return execFileSync('gh', ['auth', 'token'], {
53
49
  encoding: 'utf8',
@@ -221,6 +217,59 @@ export function createIssue(repo, { title, body, labels }) {
221
217
  return { number, url }
222
218
  }
223
219
 
220
+ /**
221
+ * List all label names for a repo.
222
+ * @param {string} repo - owner/name
223
+ * @returns {string[]}
224
+ */
225
+ export function listLabels(repo) {
226
+ const raw = execFileSync('gh', [
227
+ 'label', 'list',
228
+ '--repo', repo,
229
+ '--json', 'name',
230
+ '--jq', '.[].name',
231
+ ], {
232
+ encoding: 'utf8',
233
+ stdio: ['ignore', 'pipe', 'ignore'],
234
+ }).trim()
235
+
236
+ if (!raw) return []
237
+ return raw.split('\n').filter(Boolean)
238
+ }
239
+
240
+ /**
241
+ * Create a label in a repo with a default color.
242
+ * @param {string} repo - owner/name
243
+ * @param {string} name - label name
244
+ */
245
+ export function createLabel(repo, name) {
246
+ execFileSync('gh', [
247
+ 'label', 'create', name,
248
+ '--repo', repo,
249
+ '--color', '#0075ca',
250
+ ], {
251
+ encoding: 'utf8',
252
+ stdio: ['ignore', 'pipe', 'pipe'],
253
+ })
254
+ }
255
+
256
+ /**
257
+ * Apply labels to an existing issue.
258
+ * @param {string} repo - owner/name
259
+ * @param {number} issueNumber
260
+ * @param {string[]} labels
261
+ */
262
+ export function addLabelsToIssue(repo, issueNumber, labels) {
263
+ execFileSync('gh', [
264
+ 'issue', 'edit', String(issueNumber),
265
+ '--repo', repo,
266
+ '--add-label', labels.join(','),
267
+ ], {
268
+ encoding: 'utf8',
269
+ stdio: ['ignore', 'pipe', 'pipe'],
270
+ })
271
+ }
272
+
224
273
  /**
225
274
  * List open issues for a repo.
226
275
  * @param {string} repo - owner/name