Skip to content

Commit 668d54a

Browse files
committed
feat(airi-plugin-claude-code): now works with Claude Code hooks
1 parent 10329fe commit 668d54a

File tree

11 files changed

+320
-136
lines changed

11 files changed

+320
-136
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ lerna-debug.log*
2020

2121
# Anything .local, especially for .env.local
2222
*.local
23+
# e.g. Claude Code settings.local.json
24+
*.local.*
2325

2426
# Audio binaries
2527
*.pcm

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,6 @@
6868
},
6969
"rust-analyzer.checkOnSave": true,
7070
"rust-analyzer.cachePriming.enable": false, // Disable cache priming on workspace startup to save some memory while working on non-Rust tasks
71+
7172
"vitest.disableWorkspaceWarning": true
7273
}

cspell.config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ words:
259259
- turborepo
260260
- unbird
261261
- unbundle
262+
- unconfig
262263
- uncrypto
263264
- unhead
264265
- Unlisten

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"eslint": "^9.37.0",
5454
"execa": "^9.6.0",
5555
"lint-staged": "^16.2.3",
56+
"publint": "^0.3.14",
5657
"rollup": "^4.52.4",
5758
"simple-git-hooks": "^2.13.1",
5859
"smol-toml": "^1.4.2",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@proj-airi/airi-plugin-claude-code",
3+
"type": "module",
4+
"version": "0.7.2-beta.3",
5+
"private": true,
6+
"description": "manifest.json description",
7+
"bin": {
8+
"tsdown": "./dist/run.mjs"
9+
},
10+
"scripts": {
11+
"build": "tsdown",
12+
"dev": "tsx ./src/run.ts",
13+
"typecheck": "tsc --noEmit"
14+
},
15+
"dependencies": {
16+
"@guiiai/logg": "catalog:",
17+
"@proj-airi/server-sdk": "workspace:*",
18+
"cac": "^6.7.14",
19+
"debug": "^4.4.3",
20+
"destr": "^2.0.5",
21+
"vue": "^3.5.22"
22+
},
23+
"devDependencies": {
24+
"@anthropic-ai/claude-code": "^2.0.11",
25+
"tsx": "^4.20.6",
26+
"vue-tsc": "^3.1.1"
27+
}
28+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Buffer } from 'node:buffer'
2+
3+
import type { HookInput } from '@anthropic-ai/claude-code'
4+
5+
import { argv, exit, stdin } from 'node:process'
6+
7+
import debug from 'debug'
8+
9+
import { Format, LogLevel, LogLevelString, useLogg } from '@guiiai/logg'
10+
import { Client } from '@proj-airi/server-sdk'
11+
import { cac } from 'cac'
12+
13+
import { name, version } from '../package.json'
14+
import { resolveComma, toArray } from './utils/general'
15+
16+
interface Options {
17+
config?: string
18+
configLoader?: 'auto' | 'native' | 'unconfig'
19+
noConfig?: boolean
20+
debug?: boolean | string | string[]
21+
logLevel?: LogLevelString.Log | LogLevelString.Warning | LogLevelString.Error
22+
failOnWarn?: boolean
23+
env?: Record<string, string>
24+
quiet?: boolean
25+
}
26+
27+
let logger = useLogg(name).withLogLevel(LogLevel.Log).withFormat(Format.Pretty)
28+
29+
const cli = cac('airi-plugin-claude-code-cli')
30+
cli.help().version(version)
31+
32+
cli
33+
.command('send', 'Pass Claude Code hook event to Channel Server', { ignoreOptionDefaultValue: true, allowUnknownOptions: true })
34+
.option('-c, --config <filename>', 'Use a custom config file')
35+
.option('--config-loader <loader>', 'Config loader to use: auto, native, unconfig', { default: 'auto' })
36+
.option('--no-config', 'Disable config file')
37+
.option('--debug [feat]', 'Show debug logs')
38+
.option('-l, --logLevel <level>', 'Set log level: info, warn, error, silent')
39+
.option('--fail-on-warn', 'Fail on warnings', { default: true })
40+
.option('--env.* <value>', 'Define env variables')
41+
.option('--quiet', 'Suppress all logs')
42+
.action(async (_, flags: Options) => {
43+
if (flags?.quiet) {
44+
logger = logger.withLogLevel(-1 as LogLevel)
45+
}
46+
else {
47+
logger = logger.withLogLevelString(flags?.logLevel ?? LogLevelString.Log)
48+
}
49+
50+
async function readStdin(): Promise<string> {
51+
const chunks: string[] = []
52+
for await (const chunk of stdin) {
53+
chunks.push((chunk as Buffer).toString('utf-8'))
54+
}
55+
56+
return chunks.join('')
57+
}
58+
59+
if (stdin.isTTY) {
60+
throw new Error('`send` doesn\'t work without stdin input, Claude Code hooks events are expected to be piped to this command.')
61+
}
62+
63+
const stdinInput = await readStdin()
64+
if (!stdinInput.trim()) {
65+
throw new Error('`send` received empty stdin input, Claude Code hooks events are expected to be piped to this command.')
66+
}
67+
68+
const hookEvent = JSON.parse(stdinInput) as HookInput
69+
70+
if (hookEvent.hook_event_name === 'UserPromptSubmit') {
71+
const channelServer = new Client({ name: 'proj-airi:plugin-claude-code', autoConnect: false })
72+
await channelServer.connect()
73+
74+
channelServer.send({ type: 'input:text', data: { text: hookEvent.prompt } })
75+
}
76+
})
77+
78+
export async function runCLI(): Promise<void> {
79+
cli.parse(argv, { run: false })
80+
81+
if (cli.options.debug) {
82+
let namespace: string
83+
if (cli.options.debug === true) {
84+
namespace = `${name}:*`
85+
}
86+
else {
87+
// support debugging multiple flags with comma-separated list
88+
namespace = resolveComma(toArray(cli.options.debug))
89+
.map(v => `${name}:${v}`)
90+
.join(',')
91+
}
92+
93+
const enabled = debug.disable()
94+
if (enabled)
95+
namespace += `,${enabled}`
96+
97+
debug.enable(namespace)
98+
debug(`${name}:debug`)('Debugging enabled', namespace)
99+
}
100+
101+
try {
102+
await cli.runMatchedCommand()
103+
}
104+
catch (error) {
105+
logger.withError(error).error('running failed')
106+
exit(1)
107+
}
108+
}

plugins/airi-plugin-claude-code/src/index.ts

Whitespace-only changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env node
2+
import module from 'node:module'
3+
4+
import { runCLI } from './cli'
5+
6+
try {
7+
module.enableCompileCache?.()
8+
}
9+
catch {}
10+
runCLI()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* https://github.com/rolldown/tsdown/blob/a7e267ab7f4e836e836dab5cecf029fc35fd1939/src/utils/general.ts
3+
*/
4+
export function toArray<T>(
5+
val: T | T[] | null | undefined,
6+
defaultValue?: T,
7+
): T[] {
8+
if (Array.isArray(val)) {
9+
return val
10+
}
11+
else if (val == null) {
12+
if (defaultValue)
13+
return [defaultValue]
14+
return []
15+
}
16+
else {
17+
return [val]
18+
}
19+
}
20+
21+
export function resolveComma<T extends string>(arr: T[]): T[] {
22+
return arr.flatMap(format => format.split(',') as T[])
23+
}
24+
25+
export function resolveRegex<T>(str: T): T | RegExp {
26+
if (
27+
typeof str === 'string'
28+
&& str.length > 2
29+
&& str[0] === '/'
30+
&& str.at(-1) === '/'
31+
) {
32+
return new RegExp(str.slice(1, -1))
33+
}
34+
return str
35+
}
36+
37+
export function debounce<T extends (...args: any[]) => any>(
38+
fn: T,
39+
wait: number,
40+
): T {
41+
let timeout: ReturnType<typeof setTimeout> | undefined
42+
return function (this: any, ...args: any[]) {
43+
if (timeout)
44+
clearTimeout(timeout)
45+
timeout = setTimeout(() => {
46+
timeout = undefined
47+
fn.apply(this, args)
48+
}, wait)
49+
} as T
50+
}
51+
52+
export function slash(string: string): string {
53+
return string.replaceAll('\\', '/')
54+
}
55+
56+
export const noop = <T>(v: T): T => v
57+
58+
export function matchPattern(
59+
id: string,
60+
patterns: (string | RegExp)[],
61+
): boolean {
62+
return patterns.some((pattern) => {
63+
if (pattern instanceof RegExp) {
64+
pattern.lastIndex = 0
65+
return pattern.test(id)
66+
}
67+
return id === pattern
68+
})
69+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig } from 'tsdown'
2+
3+
export default defineConfig([
4+
{
5+
entry: ['./src/run.ts'],
6+
inlineOnly: [],
7+
platform: 'node',
8+
dts: true,
9+
fixedExtension: true,
10+
unused: true,
11+
publint: true,
12+
},
13+
])

0 commit comments

Comments
 (0)