Merge branch 'pr/smallflyingpig/36'

This commit is contained in:
claude-code-best 2026-04-02 21:38:12 +08:00
commit 0d0304d6a5
6 changed files with 297 additions and 14 deletions

View File

@ -116,18 +116,21 @@ export function rollWithSeed(seed: string): Roll {
return rollFrom(mulberry32(hashString(seed))) return rollFrom(mulberry32(hashString(seed)))
} }
export function generateSeed(): string {
return `rehatch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export function companionUserId(): string { export function companionUserId(): string {
const config = getGlobalConfig() const config = getGlobalConfig()
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
} }
// Regenerate bones from userId, merge with stored soul. Bones never persist // Regenerate bones from seed or userId, merge with stored soul.
// so species renames and SPECIES-array edits can't break stored companions,
// and editing config.companion can't fake a rarity.
export function getCompanion(): Companion | undefined { export function getCompanion(): Companion | undefined {
const stored = getGlobalConfig().companion const stored = getGlobalConfig().companion
if (!stored) return undefined if (!stored) return undefined
const { bones } = roll(companionUserId()) const seed = stored.seed ?? companionUserId()
const { bones } = rollWithSeed(seed)
// bones last so stale bones fields in old-format configs get overridden // bones last so stale bones fields in old-format configs get overridden
return { ...stored, ...bones } return { ...stored, ...bones }
} }

View File

@ -111,6 +111,7 @@ export type CompanionBones = {
export type CompanionSoul = { export type CompanionSoul = {
name: string name: string
personality: string personality: string
seed?: string
} }
export type Companion = CompanionBones & export type Companion = CompanionBones &

View File

@ -115,11 +115,8 @@ const forkCmd = feature('FORK_SUBAGENT')
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js') require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
).default ).default
: null : null
const buddy = feature('BUDDY') // buddy loaded directly (not feature-gated) for this build
? ( import buddy from './commands/buddy/index.js'
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
).default
: null
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
import thinkback from './commands/thinkback/index.js' import thinkback from './commands/thinkback/index.js'
import thinkbackPlay from './commands/thinkback-play/index.js' import thinkbackPlay from './commands/thinkback-play/index.js'
@ -319,7 +316,7 @@ const COMMANDS = memoize((): Command[] => [
vim, vim,
...(webCmd ? [webCmd] : []), ...(webCmd ? [webCmd] : []),
...(forkCmd ? [forkCmd] : []), ...(forkCmd ? [forkCmd] : []),
...(buddy ? [buddy] : []), buddy,
...(proactive ? [proactive] : []), ...(proactive ? [proactive] : []),
...(briefCommand ? [briefCommand] : []), ...(briefCommand ? [briefCommand] : []),
...(assistantCommand ? [assistantCommand] : []), ...(assistantCommand ? [assistantCommand] : []),

258
src/commands/buddy/buddy.ts Normal file
View File

@ -0,0 +1,258 @@
import {
getCompanion,
rollWithSeed,
generateSeed,
type Roll,
} from '../../buddy/companion.js'
import {
type StoredCompanion,
RARITY_STARS,
STAT_NAMES,
SPECIES,
} from '../../buddy/types.js'
import { renderSprite } from '../../buddy/sprites.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import type { LocalCommandCall } from '../../types/command.js'
// Species → default name fragments for hatch (no API needed)
const SPECIES_NAMES: Record<string, string> = {
duck: 'Waddles',
goose: 'Goosberry',
blob: 'Gooey',
cat: 'Whiskers',
dragon: 'Ember',
octopus: 'Inky',
owl: 'Hoots',
penguin: 'Waddleford',
turtle: 'Shelly',
snail: 'Trailblazer',
ghost: 'Casper',
axolotl: 'Axie',
capybara: 'Chill',
cactus: 'Spike',
robot: 'Byte',
rabbit: 'Flops',
mushroom: 'Spore',
chonk: 'Chonk',
}
const SPECIES_PERSONALITY: Record<string, string> = {
duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.',
goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.',
blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.',
cat: 'Independent and judgmental. Watches you type with mild disdain.',
dragon: 'Fiery and passionate about architecture. Hoards good variable names.',
octopus: 'Multitasker extraordinaire. Wraps tentacles around every problem at once.',
owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.',
penguin: 'Cool under pressure. Slides gracefully through merge conflicts.',
turtle: 'Patient and thorough. Believes slow and steady wins the deploy.',
snail: 'Methodical and leaves a trail of useful comments. Never rushes.',
ghost: 'Ethereal and appears at the worst possible moments with spooky insights.',
axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.',
capybara: 'Zen master. Remains calm while everything around is on fire.',
cactus: 'Prickly on the outside but full of good intentions. Thrives on neglect.',
robot: 'Efficient and literal. Processes feedback in binary.',
rabbit: 'Energetic and hops between tasks. Finishes before you start.',
mushroom: 'Quietly insightful. Grows on you over time.',
chonk: 'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.',
}
function speciesLabel(species: string): string {
return species.charAt(0).toUpperCase() + species.slice(1)
}
function renderStats(stats: Record<string, number>): string {
const lines = STAT_NAMES.map(name => {
const val = stats[name] ?? 0
const filled = Math.round(val / 5)
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled)
return ` ${name.padEnd(10)} ${bar} ${val}`
})
return lines.join('\n')
}
function companionInfoText(roll: Roll): string {
const { bones } = roll
const sprite = renderSprite(bones, 0)
const stars = RARITY_STARS[bones.rarity]
const name = SPECIES_NAMES[bones.species] ?? 'Buddy'
const shiny = bones.shiny ? ' ✨ Shiny!' : ''
return [
sprite.join('\n'),
'',
` ${name} the ${speciesLabel(bones.species)}${shiny}`,
` Rarity: ${stars} (${bones.rarity})`,
` Eye: ${bones.eye} Hat: ${bones.hat}`,
'',
' Stats:',
renderStats(bones.stats),
].join('\n')
}
export const call: LocalCommandCall = async (args, _context) => {
const sub = args.trim().toLowerCase()
const config = getGlobalConfig()
// /buddy — show current companion or hint to hatch
if (sub === '') {
const companion = getCompanion()
if (!companion) {
return {
type: 'text',
value:
"You don't have a companion yet! Use /buddy hatch to get one.",
}
}
const stars = RARITY_STARS[companion.rarity]
const sprite = renderSprite(companion, 0)
const shiny = companion.shiny ? ' ✨ Shiny!' : ''
const lines = [
sprite.join('\n'),
'',
` ${companion.name} the ${speciesLabel(companion.species)}${shiny}`,
` Rarity: ${stars} (${companion.rarity})`,
` Eye: ${companion.eye} Hat: ${companion.hat}`,
companion.personality ? `\n "${companion.personality}"` : '',
'',
' Stats:',
renderStats(companion.stats),
'',
' Commands: /buddy pet /buddy mute /buddy unmute /buddy hatch /buddy rehatch',
]
return { type: 'text', value: lines.join('\n') }
}
// /buddy hatch — create a new companion
if (sub === 'hatch') {
if (config.companion) {
return {
type: 'text',
value: `You already have a companion! Use /buddy to see it.\n(Tip: /buddy hatch again will re-roll a new one.)`,
}
}
const seed = generateSeed()
const r = rollWithSeed(seed)
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
const personality =
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
const stored: StoredCompanion = {
name,
personality,
seed,
hatchedAt: Date.now(),
}
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
const stars = RARITY_STARS[r.bones.rarity]
const sprite = renderSprite(r.bones, 0)
const shiny = r.bones.shiny ? ' ✨ Shiny!' : ''
const lines = [
' 🎉 A wild companion appeared!',
'',
sprite.join('\n'),
'',
` ${name} the ${speciesLabel(r.bones.species)}${shiny}`,
` Rarity: ${stars} (${r.bones.rarity})`,
` "${personality}"`,
'',
' Your companion will now appear beside your input box!',
]
return { type: 'text', value: lines.join('\n') }
}
// /buddy pet — trigger heart animation
if (sub === 'pet') {
const companion = getCompanion()
if (!companion) {
return {
type: 'text',
value:
"You don't have a companion yet! Use /buddy hatch to get one.",
}
}
// Import setAppState dynamically to update companionPetAt
try {
const { setAppState } = await import('../../state/AppStateStore.js')
setAppState(prev => ({
...prev,
companionPetAt: Date.now(),
}))
} catch {
// If AppState is not available (non-interactive), just show text
}
return {
type: 'text',
value: ` ${renderSprite(companion, 0).join('\n')}\n\n ${companion.name} purrs happily! ♥`,
}
}
// /buddy mute
if (sub === 'mute') {
if (config.companionMuted) {
return { type: 'text', value: ' Companion is already muted.' }
}
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: true }))
return { type: 'text', value: ' Companion muted. It will hide quietly. Use /buddy unmute to bring it back.' }
}
// /buddy unmute
if (sub === 'unmute') {
if (!config.companionMuted) {
return { type: 'text', value: ' Companion is not muted.' }
}
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
return { type: 'text', value: ' Companion unmuted! Welcome back.' }
}
// /buddy rehatch — re-roll a new companion (replaces existing)
if (sub === 'rehatch') {
const seed = generateSeed()
const r = rollWithSeed(seed)
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
const personality =
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
const stored: StoredCompanion = {
name,
personality,
seed,
hatchedAt: Date.now(),
}
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
const stars = RARITY_STARS[r.bones.rarity]
const sprite = renderSprite(r.bones, 0)
const shiny = r.bones.shiny ? ' ✨ Shiny!' : ''
const lines = [
' 🎉 A new companion appeared!',
'',
sprite.join('\n'),
'',
` ${name} the ${speciesLabel(r.bones.species)}${shiny}`,
` Rarity: ${stars} (${r.bones.rarity})`,
` "${personality}"`,
'',
' Your old companion has been replaced!',
]
return { type: 'text', value: lines.join('\n') }
}
// Unknown subcommand
return {
type: 'text',
value:
' Unknown command: /buddy ' +
sub +
'\n Commands: /buddy (info) /buddy hatch /buddy rehatch /buddy pet /buddy mute /buddy unmute',
}
}

View File

@ -1,3 +1,11 @@
// Auto-generated stub — replace with real implementation import type { Command } from '../../commands.js'
const _default: Record<string, unknown> = {};
export default _default; const buddy = {
type: 'local',
name: 'buddy',
description: 'View and manage your companion buddy',
supportsNonInteractive: false,
load: () => import('./buddy.js'),
} satisfies Command
export default buddy

View File

@ -1,6 +1,22 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { feature } from 'bun:bundle';
// Runtime polyfill for bun:bundle (build-time macros)
const feature = (name: string) => name === "BUDDY";
if (typeof globalThis.MACRO === "undefined") {
(globalThis as any).MACRO = {
VERSION: "2.1.888",
BUILD_TIME: new Date().toISOString(),
FEEDBACK_CHANNEL: "",
ISSUES_EXPLAINER: "",
NATIVE_PACKAGE_URL: "",
PACKAGE_URL: "",
VERSION_CHANGELOG: "",
};
}
// Build-time constants — normally replaced by Bun bundler at compile time
(globalThis as any).BUILD_TARGET = "external";
(globalThis as any).BUILD_ENV = "production";
(globalThis as any).INTERFACE_TYPE = "stdio";
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
// eslint-disable-next-line custom-rules/no-top-level-side-effects // eslint-disable-next-line custom-rules/no-top-level-side-effects