tissues 0.6.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tissues",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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
@@ -7,6 +7,7 @@ import { createCommand, draftCommand } from './commands/create.js'
7
7
  import { listCommand } from './commands/list.js'
8
8
  import { statusCommand } from './commands/status.js'
9
9
  import { templatesCommand } from './commands/templates.js'
10
+ import { enhancementsCommand } from './commands/enhancements.js'
10
11
  import { draftsCommand } from './commands/drafts.js'
11
12
  import { aiCommand } from './commands/ai.js'
12
13
  import { store } from './lib/config.js'
@@ -21,7 +22,7 @@ function authDescription() {
21
22
 
22
23
  const SKIP_REPO_BANNER = new Set([
23
24
  'auth', 'login', 'status', 'switch', 'logout',
24
- 'config', 'drafts', 'publish', 'templates', 'ai',
25
+ 'config', 'drafts', 'publish', 'templates', 'enhancements', 'ai',
25
26
  ])
26
27
 
27
28
  program
@@ -67,5 +68,6 @@ program.addCommand(draftCommand)
67
68
  program.addCommand(listCommand)
68
69
  program.addCommand(statusCommand)
69
70
  program.addCommand(templatesCommand)
71
+ program.addCommand(enhancementsCommand)
70
72
  program.addCommand(draftsCommand)
71
73
  program.addCommand(aiCommand)
@@ -104,6 +104,7 @@ export const aiCommand = new Command('ai')
104
104
  .option('--dry-run', 'Preview the create command without executing')
105
105
  .option('--yes, -y', 'Skip confirmation, run create immediately')
106
106
  .option('--repo <repo>', 'Override active repo')
107
+ .option('--enhancements <names>', 'Comma-separated enhancement names to run')
107
108
  .option('--provider <name>', 'AI provider override')
108
109
  .option('--model <name>', 'AI model override')
109
110
  .action(async (promptWords, opts) => {
@@ -240,6 +241,7 @@ export const aiCommand = new Command('ai')
240
241
  body: plan.body || undefined,
241
242
  labels: plan.labels || undefined,
242
243
  template: plan.template || undefined,
244
+ enhancements: opts.enhancements || undefined,
243
245
  })
244
246
  if (result === null) process.exit(0)
245
247
  } catch (err) {
@@ -9,7 +9,7 @@ import { theme } from '../lib/theme.js'
9
9
  import { listTemplates } from '../lib/templates.js'
10
10
  import { pickRepo } from '../lib/repo-picker.js'
11
11
  import { store } from '../lib/config.js'
12
- import { listProviders } from '../lib/ai/index.js'
12
+ import { listProviders, listAllProviders } from '../lib/ai/index.js'
13
13
  import { listModels as listOllamaModels } from '../lib/ai/adapters/ollama.js'
14
14
 
15
15
  // ---------------------------------------------------------------------------
@@ -68,6 +68,8 @@ function coerceValue(dotKey, raw) {
68
68
  throw new Error(`Expected true/false for ${dotKey}, got: ${raw}`)
69
69
  }
70
70
  if (raw === 'null') return null
71
+ // Auto-coerce pure numeric strings when no default type is known
72
+ if (defaultVal === undefined && /^\d+$/.test(raw)) return Number(raw)
71
73
  return raw
72
74
  }
73
75
 
@@ -135,11 +137,16 @@ const PROVIDER_LABELS = {
135
137
  async function menuActiveProvider() {
136
138
  const cfg = loadConfig()
137
139
  const current = readUserConfig()
138
- const providers = listProviders()
140
+ const allProviders = listAllProviders(cfg)
141
+ const builtIn = listProviders()
139
142
 
140
143
  const defaultProvider = BUILT_IN_DEFAULTS.ai.provider
141
144
  const choices = [
142
- ...providers.map((p) => ({ name: PROVIDER_LABELS[p] || p, value: p })),
145
+ ...allProviders.map((p) => {
146
+ const isCustom = !builtIn.includes(p)
147
+ const label = isCustom ? `${p} ${dim('(custom)')}` : (PROVIDER_LABELS[p] || p)
148
+ return { name: label, value: p }
149
+ }),
143
150
  { name: 'None (disable AI)', value: 'none' },
144
151
  { name: dim(`Restore default (${defaultProvider})`), value: 'restore' },
145
152
  { name: dim('Back'), value: 'back' },
@@ -166,6 +173,13 @@ async function menuActiveProvider() {
166
173
  updated = await configureOpenAICompat(updated)
167
174
  } else if (provider === 'command') {
168
175
  updated = await configureCommand(updated)
176
+ } else if (!builtIn.includes(provider)) {
177
+ // Named CLI command — just set as default provider
178
+ updated = setNestedValue(updated, 'ai.enabled', true)
179
+ updated = setNestedValue(updated, 'ai.provider', provider)
180
+ writeUserConfig(updated)
181
+ const cmd = cfg.ai?.providers?.[provider]?.command || cfg.ai?.commands?.[provider]?.command || '(not set)'
182
+ console.log(green(` ✔ Default provider: ${provider} ${dim(`→ ${cmd}`)}`))
169
183
  } else {
170
184
  updated = setNestedValue(updated, 'ai.enabled', true)
171
185
  updated = setNestedValue(updated, 'ai.provider', provider)
@@ -518,7 +532,8 @@ function formatRoute(rule) {
518
532
  const match = Object.entries(rule.match || {})
519
533
  .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(',') : v}`)
520
534
  .join(', ')
521
- return `${match} ${dim('->')} ${rule.provider || 'default'}${rule.model ? dim(` (${rule.model})`) : ''}`
535
+ const enhTag = rule.enhancements?.length ? dim(` [${rule.enhancements.join(',')}]`) : ''
536
+ return `${match} ${dim('->')} ${rule.provider || 'default'}${rule.model ? dim(` (${rule.model})`) : ''}${enhTag}`
522
537
  }
523
538
 
524
539
  async function editRoute(rule) {
@@ -571,8 +586,10 @@ async function editRoute(rule) {
571
586
  }
572
587
 
573
588
  // Provider
574
- const providerChoices = providers.map((p) => ({
575
- name: p.charAt(0).toUpperCase() + p.slice(1),
589
+ const allProviders = listAllProviders(cfg)
590
+ const builtInSet = new Set(listProviders())
591
+ const providerChoices = allProviders.map((p) => ({
592
+ name: builtInSet.has(p) ? (p.charAt(0).toUpperCase() + p.slice(1)) : `${p} ${dim('(custom)')}`,
576
593
  value: p,
577
594
  }))
578
595
  const provider = await promptOrBack(() =>
@@ -591,7 +608,21 @@ async function editRoute(rule) {
591
608
  )
592
609
  if (model === Symbol.for('back')) return null
593
610
 
594
- return { match, provider, model: model || undefined }
611
+ // Enhancements filter (optional)
612
+ const enhRaw = await promptOrBack(() =>
613
+ input({
614
+ message: 'Enhancements (comma-separated, enter to skip)',
615
+ default: rule?.enhancements?.join(', ') || '',
616
+ theme,
617
+ }),
618
+ )
619
+ if (enhRaw === Symbol.for('back')) return null
620
+
621
+ const enhancements = enhRaw
622
+ ? enhRaw.split(',').map((s) => s.trim()).filter(Boolean)
623
+ : undefined
624
+
625
+ return { match, provider, model: model || undefined, enhancements: enhancements?.length ? enhancements : undefined }
595
626
  }
596
627
 
597
628
  async function menuRoutingRules() {
@@ -660,87 +691,130 @@ async function menuRoutingRules() {
660
691
  }
661
692
 
662
693
  // ---------------------------------------------------------------------------
663
- // Submenu: Enhancement pipeline
694
+ // Submenu: Custom Providers (named command providers)
664
695
  // ---------------------------------------------------------------------------
665
696
 
666
- const STEP_NAMES = ['triage', 'dedup', 'context', 'scope', 'complexity', 'risk', 'labels', 'format']
667
- const STEP_LABELS = {
668
- triage: 'Input analysis',
669
- dedup: 'Duplicate check',
670
- context: 'Context extraction',
671
- scope: 'Scope analysis',
672
- complexity: 'Complexity scoring',
673
- risk: 'Risk assessment',
674
- labels: 'Label suggestion',
675
- format: 'Body formatting',
697
+ /** Migrate ai.commands ai.providers on disk if needed. */
698
+ function migrateProvidersOnDisk(current) {
699
+ if (!current.ai) return current
700
+ let updated = current
701
+ if (current.ai.commands) {
702
+ const existing = current.ai.providers || {}
703
+ updated = setNestedValue(updated, 'ai.providers', { ...current.ai.commands, ...existing })
704
+ const ai = { ...updated.ai }
705
+ delete ai.commands
706
+ updated = { ...updated, ai }
707
+ }
708
+ return updated
676
709
  }
677
710
 
678
- async function menuPipeline() {
679
- while (true) {
680
- const cfg = loadConfig()
681
- const pipeline = cfg.ai?.pipeline || BUILT_IN_DEFAULTS.ai.pipeline
682
- const enabled = pipeline.enabled !== false
683
-
684
- const choices = [
685
- {
686
- name: `${'Pipeline'.padEnd(18)} ${enabled ? green('enabled') : dim('disabled')}`,
687
- value: 'toggle',
688
- },
689
- ]
690
-
691
- if (enabled) {
692
- for (const name of STEP_NAMES) {
693
- const setting = pipeline.steps?.[name] || 'auto'
694
- const color = setting === 'always' ? green : setting === 'never' ? red : dim
695
- choices.push({
696
- name: ` ${(STEP_LABELS[name] || name).padEnd(22)} ${color(setting)}`,
697
- value: name,
698
- })
699
- }
711
+ async function menuProviders() {
712
+ // One-time disk migration when user opens the menu
713
+ {
714
+ const current = readUserConfig()
715
+ if (current.ai?.commands) {
716
+ const migrated = migrateProvidersOnDisk(current)
717
+ writeUserConfig(migrated)
700
718
  }
719
+ }
701
720
 
702
- choices.push({ name: dim('Restore defaults'), value: 'restore' })
721
+ while (true) {
722
+ const cfg = loadConfig()
723
+ const providers = cfg.ai?.providers || cfg.ai?.commands || {}
724
+ const names = Object.keys(providers)
725
+
726
+ const choices = names.map((name) => {
727
+ const entry = providers[name]
728
+ const cmd = entry.command || dim('(no command)')
729
+ const timeout = entry.timeout ? dim(` ${entry.timeout}ms`) : ''
730
+ return { name: `${name.padEnd(18)} ${dim(cmd)}${timeout}`, value: name }
731
+ })
732
+ choices.push({ name: green('+ Add provider'), value: 'add' })
703
733
  choices.push({ name: dim('Back'), value: 'back' })
704
734
 
705
- const chosen = await promptOrBack(() => select({ message: 'Enhancement Pipeline', choices, theme }))
735
+ const chosen = await promptOrBack(() => select({ message: 'Custom Providers', choices, theme }))
706
736
  if (chosen === Symbol.for('back') || chosen === 'back') return
707
737
 
708
- if (chosen === 'toggle') {
709
- const current = readUserConfig()
710
- const updated = setNestedValue(current, 'ai.pipeline.enabled', !enabled)
711
- writeUserConfig(updated)
712
- console.log(green(` ✔ Pipeline ${!enabled ? 'enabled' : 'disabled'}`))
713
- continue
714
- }
738
+ if (chosen === 'add') {
739
+ const name = await promptOrBack(() =>
740
+ input({ message: 'Provider name (e.g. my-gemini)', theme }),
741
+ )
742
+ if (name === Symbol.for('back') || !name) continue
743
+ const key = name.trim().toLowerCase().replace(/\s+/g, '-')
744
+ if (!key) continue
745
+ if (providers[key]) {
746
+ console.log(yellow(` "${key}" already exists — select it to edit`))
747
+ continue
748
+ }
749
+ const cmd = await promptOrBack(() =>
750
+ input({ message: 'Shell command to run', theme }),
751
+ )
752
+ if (cmd === Symbol.for('back') || !cmd) continue
753
+ const timeoutRaw = await promptOrBack(() =>
754
+ input({ message: 'Timeout ms (enter for default 60s)', default: '', theme }),
755
+ )
756
+ if (timeoutRaw === Symbol.for('back')) continue
715
757
 
716
- if (chosen === 'restore') {
717
758
  const current = readUserConfig()
718
- const updated = setNestedValue(current, 'ai.pipeline', BUILT_IN_DEFAULTS.ai.pipeline)
759
+ let updated = setNestedValue(current, `ai.providers.${key}.command`, cmd)
760
+ if (timeoutRaw) {
761
+ const ms = Number(timeoutRaw)
762
+ if (!isNaN(ms) && ms > 0) {
763
+ updated = setNestedValue(updated, `ai.providers.${key}.timeout`, ms)
764
+ }
765
+ }
719
766
  writeUserConfig(updated)
720
- console.log(green('Pipeline settings restored to defaults'))
767
+ console.log(green(`Added: ${key} ${cmd}`))
721
768
  continue
722
769
  }
723
770
 
724
- // Toggle a step setting: always → auto → never → always
725
- const stepSetting = await promptOrBack(() =>
771
+ // Edit/delete existing provider
772
+ const entry = providers[chosen]
773
+ const action = await promptOrBack(() =>
726
774
  select({
727
- message: `${STEP_LABELS[chosen] || chosen}`,
775
+ message: `${chosen}: ${entry.command || '(no command)'}`,
728
776
  choices: [
729
- { name: 'always — run every time', value: 'always' },
730
- { name: 'auto — run when heuristics suggest it', value: 'auto' },
731
- { name: 'never — skip this step', value: 'never' },
777
+ { name: 'Edit', value: 'edit' },
778
+ { name: red('Delete'), value: 'delete' },
732
779
  { name: dim('Back'), value: 'back' },
733
780
  ],
734
- default: pipeline.steps?.[chosen] || 'auto',
735
781
  theme,
736
782
  }),
737
783
  )
738
- if (stepSetting === Symbol.for('back') || stepSetting === 'back') continue
784
+ if (action === Symbol.for('back') || action === 'back') continue
739
785
 
740
786
  const current = readUserConfig()
741
- const updated = setNestedValue(current, `ai.pipeline.steps.${chosen}`, stepSetting)
742
- writeUserConfig(updated)
743
- console.log(green(` ✔ ${STEP_LABELS[chosen]}: ${stepSetting}`))
787
+ if (action === 'delete') {
788
+ const provs = { ...(current.ai?.providers || {}) }
789
+ delete provs[chosen]
790
+ const updated = setNestedValue(current, 'ai.providers', provs)
791
+ writeUserConfig(updated)
792
+ console.log(green(` ✔ Deleted: ${chosen}`))
793
+ } else {
794
+ const cmd = await promptOrBack(() =>
795
+ input({ message: 'Shell command', default: entry.command || '', theme }),
796
+ )
797
+ if (cmd === Symbol.for('back')) continue
798
+ const timeoutRaw = await promptOrBack(() =>
799
+ input({ message: 'Timeout ms (enter for default)', default: entry.timeout ? String(entry.timeout) : '', theme }),
800
+ )
801
+ if (timeoutRaw === Symbol.for('back')) continue
802
+
803
+ let updated = current
804
+ if (cmd) updated = setNestedValue(updated, `ai.providers.${chosen}.command`, cmd)
805
+ if (timeoutRaw) {
806
+ const ms = Number(timeoutRaw)
807
+ if (!isNaN(ms) && ms > 0) {
808
+ updated = setNestedValue(updated, `ai.providers.${chosen}.timeout`, ms)
809
+ }
810
+ } else {
811
+ // Clear timeout if empty
812
+ const provs = { ...(updated.ai?.providers || {}) }
813
+ if (provs[chosen]) { delete provs[chosen].timeout; updated = setNestedValue(updated, 'ai.providers', provs) }
814
+ }
815
+ writeUserConfig(updated)
816
+ console.log(green(` ✔ Updated: ${chosen}`))
817
+ }
744
818
  }
745
819
  }
746
820
 
@@ -910,7 +984,7 @@ async function runWizard() {
910
984
  template: cfg.templates?.default || 'default',
911
985
  safety: `${cfg.safety?.maxPerHour || '?'}/hr, burst ${cfg.safety?.burstLimit || '?'}`,
912
986
  routes: `${(cfg.ai?.routes || []).length} rule${(cfg.ai?.routes || []).length === 1 ? '' : 's'}`,
913
- pipeline: cfg.ai?.pipeline?.enabled !== false ? green('enabled') : dim('disabled'),
987
+ providers: `${Object.keys(cfg.ai?.providers || cfg.ai?.commands || {}).length} registered`,
914
988
  backups: `${getBackupFiles().length} saved`,
915
989
  }
916
990
 
@@ -921,8 +995,8 @@ async function runWizard() {
921
995
  { name: `${'API Keys'.padEnd(18)} ${dim(summaries.keys)}`, value: 'keys' },
922
996
  { name: `${'Token Budgets'.padEnd(18)} ${dim(summaries.budgets)}`, value: 'budgets' },
923
997
  { name: `${'Safety Limits'.padEnd(18)} ${dim(summaries.safety)}`, value: 'safety' },
924
- { name: `${'Routing Rules'.padEnd(18)} ${dim(summaries.routes)}`, value: 'routes' },
925
- { name: `${'AI Pipeline'.padEnd(18)} ${summaries.pipeline}`, value: 'pipeline' },
998
+ { name: `${'Routing Rules'.padEnd(18)} ${dim(summaries.routes)}`, value: 'routes' },
999
+ { name: `${'Custom Providers'.padEnd(18)} ${dim(summaries.providers)}`, value: 'providers' },
926
1000
  { name: `${'Backup & Restore'.padEnd(18)} ${dim(summaries.backups)}`, value: 'backup' },
927
1001
  { name: dim('Done'), value: 'done' },
928
1002
  ]
@@ -944,9 +1018,9 @@ async function runWizard() {
944
1018
  case 'budgets': await menuTokenBudgets(); break
945
1019
  case 'template': await menuDefaultTemplate(); break
946
1020
  case 'safety': await menuSafety(); break
947
- case 'routes': await menuRoutingRules(); break
948
- case 'pipeline': await menuPipeline(); break
949
- case 'backup': await menuBackupRestore(); break
1021
+ case 'routes': await menuRoutingRules(); break
1022
+ case 'providers': await menuProviders(); break
1023
+ case 'backup': await menuBackupRestore(); break
950
1024
  }
951
1025
  } catch (err) {
952
1026
  if (isCancelled(err)) continue
@@ -401,11 +401,15 @@ export async function runCreate(opts) {
401
401
  let body = renderedTemplate
402
402
  let pipelineResult = null
403
403
  if (opts.enhance !== false && !opts.dryRun) {
404
+ const enhancementNames = opts.enhancements
405
+ ? opts.enhancements.split(',').map((s) => s.trim()).filter(Boolean)
406
+ : undefined
404
407
  const aiContext = {
405
408
  template: templateName,
406
409
  labels: opts.labels ? opts.labels.split(',').map((l) => l.trim()).filter(Boolean) : [],
407
410
  provider: opts.provider,
408
411
  model: opts.model,
412
+ enhancements: enhancementNames,
409
413
  }
410
414
  if (checkAvailable(config, aiContext)) {
411
415
  const pipelineConfig = config.ai?.pipeline || {}
@@ -435,7 +439,10 @@ export async function runCreate(opts) {
435
439
  existingIssues,
436
440
  repoLabels,
437
441
  }, {
438
- onStepStart(step) { aiSpinner.text = `${step.displayName}...` },
442
+ onStepStart(step) {
443
+ if (!aiSpinner.isSpinning) aiSpinner = ora().start()
444
+ aiSpinner.text = `${step.displayName}...`
445
+ },
439
446
  onStepDone(step, ctx) {
440
447
  let detail = ''
441
448
  if (step.name === 'triage') detail = ctx.title ? `: ${ctx.title}` : ''
@@ -445,13 +452,12 @@ export async function runCreate(opts) {
445
452
  if (step.name === 'risk') detail = ctx.risk != null ? `: ${ctx.risk}/10` : ''
446
453
  if (step.name === 'labels') detail = ctx.aiLabels?.length ? `: ${ctx.aiLabels.join(', ')}` : ''
447
454
  aiSpinner.succeed(`${step.displayName}${detail}`)
448
- aiSpinner = ora('').start()
449
455
  },
450
456
  onStepSkip(step) { /* silent */ },
451
457
  onStepFail(step, err) {
458
+ if (!aiSpinner.isSpinning) aiSpinner = ora().start()
452
459
  aiSpinner.warn(`${step.displayName} failed`)
453
460
  console.warn(dim(` ${err.message}`))
454
- aiSpinner = ora('').start()
455
461
  },
456
462
  }, aiContext)
457
463
 
@@ -760,6 +766,7 @@ export const createCommand = new Command('create')
760
766
  .option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
761
767
  .option('--pipeline', 'Force multi-step AI pipeline even if config disabled')
762
768
  .option('--no-pipeline', 'Force single-shot AI enhancement even if pipeline enabled')
769
+ .option('--enhancements <names>', 'Comma-separated enhancement names to run (e.g. "context,risk")')
763
770
  .option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
764
771
  .option('--model <name>', 'AI model override')
765
772
  .option(