feat(airi-plugin-claude-code): now works with Claude Code hooks

This commit is contained in:
Neko Ayaka
2025-10-09 16:09:57 +08:00
parent 10329fec82
commit 668d54a9b2
11 changed files with 320 additions and 136 deletions
+108
View File
@@ -0,0 +1,108 @@
import type { Buffer } from 'node:buffer'
import type { HookInput } from '@anthropic-ai/claude-code'
import { argv, exit, stdin } from 'node:process'
import debug from 'debug'
import { Format, LogLevel, LogLevelString, useLogg } from '@guiiai/logg'
import { Client } from '@proj-airi/server-sdk'
import { cac } from 'cac'
import { name, version } from '../package.json'
import { resolveComma, toArray } from './utils/general'
interface Options {
config?: string
configLoader?: 'auto' | 'native' | 'unconfig'
noConfig?: boolean
debug?: boolean | string | string[]
logLevel?: LogLevelString.Log | LogLevelString.Warning | LogLevelString.Error
failOnWarn?: boolean
env?: Record<string, string>
quiet?: boolean
}
let logger = useLogg(name).withLogLevel(LogLevel.Log).withFormat(Format.Pretty)
const cli = cac('airi-plugin-claude-code-cli')
cli.help().version(version)
cli
.command('send', 'Pass Claude Code hook event to Channel Server', { ignoreOptionDefaultValue: true, allowUnknownOptions: true })
.option('-c, --config <filename>', 'Use a custom config file')
.option('--config-loader <loader>', 'Config loader to use: auto, native, unconfig', { default: 'auto' })
.option('--no-config', 'Disable config file')
.option('--debug [feat]', 'Show debug logs')
.option('-l, --logLevel <level>', 'Set log level: info, warn, error, silent')
.option('--fail-on-warn', 'Fail on warnings', { default: true })
.option('--env.* <value>', 'Define env variables')
.option('--quiet', 'Suppress all logs')
.action(async (_, flags: Options) => {
if (flags?.quiet) {
logger = logger.withLogLevel(-1 as LogLevel)
}
else {
logger = logger.withLogLevelString(flags?.logLevel ?? LogLevelString.Log)
}
async function readStdin(): Promise<string> {
const chunks: string[] = []
for await (const chunk of stdin) {
chunks.push((chunk as Buffer).toString('utf-8'))
}
return chunks.join('')
}
if (stdin.isTTY) {
throw new Error('`send` doesn\'t work without stdin input, Claude Code hooks events are expected to be piped to this command.')
}
const stdinInput = await readStdin()
if (!stdinInput.trim()) {
throw new Error('`send` received empty stdin input, Claude Code hooks events are expected to be piped to this command.')
}
const hookEvent = JSON.parse(stdinInput) as HookInput
if (hookEvent.hook_event_name === 'UserPromptSubmit') {
const channelServer = new Client({ name: 'proj-airi:plugin-claude-code', autoConnect: false })
await channelServer.connect()
channelServer.send({ type: 'input:text', data: { text: hookEvent.prompt } })
}
})
export async function runCLI(): Promise<void> {
cli.parse(argv, { run: false })
if (cli.options.debug) {
let namespace: string
if (cli.options.debug === true) {
namespace = `${name}:*`
}
else {
// support debugging multiple flags with comma-separated list
namespace = resolveComma(toArray(cli.options.debug))
.map(v => `${name}:${v}`)
.join(',')
}
const enabled = debug.disable()
if (enabled)
namespace += `,${enabled}`
debug.enable(namespace)
debug(`${name}:debug`)('Debugging enabled', namespace)
}
try {
await cli.runMatchedCommand()
}
catch (error) {
logger.withError(error).error('running failed')
exit(1)
}
}
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import module from 'node:module'
import { runCLI } from './cli'
try {
module.enableCompileCache?.()
}
catch {}
runCLI()
@@ -0,0 +1,69 @@
/**
* https://github.com/rolldown/tsdown/blob/a7e267ab7f4e836e836dab5cecf029fc35fd1939/src/utils/general.ts
*/
export function toArray<T>(
val: T | T[] | null | undefined,
defaultValue?: T,
): T[] {
if (Array.isArray(val)) {
return val
}
else if (val == null) {
if (defaultValue)
return [defaultValue]
return []
}
else {
return [val]
}
}
export function resolveComma<T extends string>(arr: T[]): T[] {
return arr.flatMap(format => format.split(',') as T[])
}
export function resolveRegex<T>(str: T): T | RegExp {
if (
typeof str === 'string'
&& str.length > 2
&& str[0] === '/'
&& str.at(-1) === '/'
) {
return new RegExp(str.slice(1, -1))
}
return str
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
wait: number,
): T {
let timeout: ReturnType<typeof setTimeout> | undefined
return function (this: any, ...args: any[]) {
if (timeout)
clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = undefined
fn.apply(this, args)
}, wait)
} as T
}
export function slash(string: string): string {
return string.replaceAll('\\', '/')
}
export const noop = <T>(v: T): T => v
export function matchPattern(
id: string,
patterns: (string | RegExp)[],
): boolean {
return patterns.some((pattern) => {
if (pattern instanceof RegExp) {
pattern.lastIndex = 0
return pattern.test(id)
}
return id === pattern
})
}