@vibe-forge/core 0.7.4 → 0.8.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.
Files changed (40) hide show
  1. package/package.json +4 -46
  2. package/src/env.ts +5 -25
  3. package/src/index.ts +0 -5
  4. package/src/types.ts +12 -72
  5. package/src/ws.ts +3 -12
  6. package/src/adapter/index.ts +0 -6
  7. package/src/adapter/loader.ts +0 -11
  8. package/src/adapter/type.ts +0 -112
  9. package/src/config/load.ts +0 -122
  10. package/src/config/types.ts +0 -289
  11. package/src/config.ts +0 -2
  12. package/src/controllers/benchmark/discover.ts +0 -89
  13. package/src/controllers/benchmark/index.ts +0 -24
  14. package/src/controllers/benchmark/result-store.ts +0 -46
  15. package/src/controllers/benchmark/runner.ts +0 -415
  16. package/src/controllers/benchmark/schema.ts +0 -60
  17. package/src/controllers/benchmark/types.ts +0 -80
  18. package/src/controllers/benchmark/utils.ts +0 -144
  19. package/src/controllers/benchmark/workspace.ts +0 -179
  20. package/src/controllers/config/index.ts +0 -214
  21. package/src/controllers/system/assets/completed.mp3 +0 -0
  22. package/src/controllers/system/assets/mcp.png +0 -0
  23. package/src/controllers/system/index.ts +0 -102
  24. package/src/controllers/task/generate-adapter-query-options.ts +0 -218
  25. package/src/controllers/task/index.ts +0 -2
  26. package/src/controllers/task/prepare.ts +0 -68
  27. package/src/controllers/task/run.ts +0 -191
  28. package/src/controllers/task/schema.ts +0 -131
  29. package/src/controllers/task/type.ts +0 -6
  30. package/src/hooks/call.ts +0 -74
  31. package/src/hooks/index.ts +0 -39
  32. package/src/hooks/loader.ts +0 -75
  33. package/src/hooks/runtime.ts +0 -139
  34. package/src/hooks/type.ts +0 -120
  35. package/src/utils/cache.ts +0 -58
  36. package/src/utils/create-logger.ts +0 -89
  37. package/src/utils/definition-loader.ts +0 -530
  38. package/src/utils/filter.ts +0 -26
  39. package/src/utils/string-transform.ts +0 -37
  40. package/src/utils/uuid.ts +0 -6
@@ -1,218 +0,0 @@
1
- import process from 'node:process'
2
- import { basename, dirname } from 'node:path'
3
-
4
- import type { AdapterQueryOptions } from '#~/adapter/type.js'
5
- import { DefinitionLoader } from '#~/utils/definition-loader.js'
6
- import type { Definition, Entity, Filter, Skill, Spec } from '#~/utils/definition-loader.js'
7
-
8
- const filterSkills = (
9
- skills: Definition<Skill>[],
10
- selection?: AdapterQueryOptions['skills']
11
- ) => {
12
- if (selection == null) return skills
13
-
14
- const include = selection.include != null && selection.include.length > 0
15
- ? new Set(selection.include)
16
- : undefined
17
- const exclude = new Set(selection.exclude ?? [])
18
-
19
- return skills.filter((skill) => {
20
- const name = basename(dirname(skill.path))
21
- return (include == null || include.has(name)) && !exclude.has(name)
22
- })
23
- }
24
-
25
- const dedupeSkills = (skills: Definition<Skill>[]) => {
26
- const seen = new Set<string>()
27
- return skills.filter((skill) => {
28
- if (seen.has(skill.path)) return false
29
- seen.add(skill.path)
30
- return true
31
- })
32
- }
33
-
34
- type SkillSelectionInput =
35
- | AdapterQueryOptions['skills']
36
- | Entity['skills']
37
- | Spec['skills']
38
-
39
- const toNormalizedSkillSelection = (
40
- selection?: SkillSelectionInput
41
- ): AdapterQueryOptions['skills'] | undefined => {
42
- if (selection == null) return undefined
43
-
44
- if (Array.isArray(selection)) {
45
- return selection.length > 0
46
- ? {
47
- include: selection
48
- }
49
- : undefined
50
- }
51
-
52
- if ('type' in selection && Array.isArray(selection.list)) {
53
- const list = selection.list.filter((item): item is string => typeof item === 'string')
54
- return selection.type === 'include'
55
- ? {
56
- include: list
57
- }
58
- : {
59
- exclude: list
60
- }
61
- }
62
-
63
- return selection
64
- }
65
-
66
- const mergeSkillSelections = (
67
- ...selections: Array<SkillSelectionInput | undefined>
68
- ): AdapterQueryOptions['skills'] | undefined => {
69
- let include: Set<string> | undefined
70
- const exclude = new Set<string>()
71
-
72
- for (const selection of selections) {
73
- const normalized = toNormalizedSkillSelection(selection)
74
- if (normalized == null) continue
75
-
76
- if (normalized.include != null && normalized.include.length > 0) {
77
- const current = new Set(normalized.include)
78
- include = include == null
79
- ? current
80
- : new Set([...include].filter(item => current.has(item)))
81
- }
82
-
83
- for (const item of normalized.exclude ?? []) {
84
- exclude.add(item)
85
- }
86
- }
87
-
88
- if (include == null && exclude.size === 0) return undefined
89
-
90
- return {
91
- include: include == null ? undefined : [...include],
92
- exclude: exclude.size === 0 ? undefined : [...exclude]
93
- }
94
- }
95
-
96
- const getIncludedSkillNames = (selection?: SkillSelectionInput): string[] => {
97
- const normalized = toNormalizedSkillSelection(selection)
98
- return normalized?.include ?? []
99
- }
100
-
101
- export async function generateAdapterQueryOptions(
102
- type: 'spec' | 'entity' | undefined,
103
- name?: string,
104
- cwd: string = process.cwd(),
105
- input?: {
106
- skills?: AdapterQueryOptions['skills']
107
- }
108
- ) {
109
- const loader = new DefinitionLoader(cwd)
110
- const options: Partial<AdapterQueryOptions> = {}
111
- const systemPromptParts: string[] = []
112
- let effectiveSkillSelection = toNormalizedSkillSelection(input?.skills)
113
-
114
- // 1. 获取数据
115
- // 1.1 获取默认数据
116
- const entities = type !== 'entity'
117
- ? await loader.loadDefaultEntities()
118
- : []
119
- const defaultSkills = await loader.loadDefaultSkills()
120
- const rules = await loader.loadDefaultRules()
121
- const specs = await loader.loadDefaultSpecs()
122
-
123
- // 1.2 获取指定数据
124
- const targetSkills: Definition<Skill>[] = []
125
- let targetBody = ''
126
- let targetToolsFilter: Filter | undefined
127
- let targetMcpServersFilter: Filter | undefined
128
- if (type && name) {
129
- const data = {
130
- spec: await loader.loadSpec(name),
131
- entity: await loader.loadEntity(name)
132
- }[type]
133
- if (!data) {
134
- throw new Error(`Failed to load ${type} ${name}`)
135
- }
136
- const { attributes, body } = data
137
- if (
138
- attributes.rules
139
- ) {
140
- // always load spec or entity tagged rules
141
- rules.push(
142
- ...(
143
- await loader.loadRules(attributes.rules, {
144
- baseDir: dirname(data.path)
145
- })
146
- ).map((rule) => ({
147
- ...rule,
148
- attributes: {
149
- ...rule.attributes,
150
- // 实体或流程中的规则为默认加载
151
- always: true
152
- }
153
- }))
154
- )
155
- }
156
- if (
157
- attributes.skills
158
- ) {
159
- effectiveSkillSelection = mergeSkillSelections(
160
- input?.skills,
161
- attributes.skills
162
- )
163
- targetSkills.push(
164
- ...filterSkills(
165
- await loader.loadSkills(getIncludedSkillNames(attributes.skills)),
166
- effectiveSkillSelection
167
- )
168
- )
169
- }
170
-
171
- targetBody = body
172
- targetToolsFilter = attributes.tools
173
- targetMcpServersFilter = attributes.mcpServers
174
- }
175
- const skills = filterSkills(defaultSkills, effectiveSkillSelection)
176
- let selectedSkillsPrompt: Definition<Skill>[] = []
177
- if (input?.skills?.include != null && input.skills.include.length > 0) {
178
- selectedSkillsPrompt = dedupeSkills(
179
- filterSkills(
180
- await loader.loadSkills(input.skills.include),
181
- effectiveSkillSelection
182
- )
183
- )
184
- }
185
-
186
- // 2. 基于数据生成上下文
187
- // 2.1 加载关联上下文
188
- systemPromptParts.push(loader.generateRulesPrompt(rules))
189
- systemPromptParts.push(loader.generateSkillsPrompt(dedupeSkills(targetSkills)))
190
- systemPromptParts.push(loader.generateSkillsPrompt(
191
- selectedSkillsPrompt.filter(skill => !targetSkills.some(target => target.path === skill.path))
192
- ))
193
- // 2.2 加载上下文路由
194
- systemPromptParts.push(loader.generateEntitiesRoutePrompt(entities))
195
- systemPromptParts.push(loader.generateSkillsRoutePrompt(skills))
196
- systemPromptParts.push(loader.generateSpecRoutePrompt(specs))
197
- // 2.3 加载目标上下文与配置
198
- systemPromptParts.push(targetBody)
199
- targetToolsFilter && (
200
- options.tools = targetToolsFilter
201
- )
202
- targetMcpServersFilter && (
203
- options.mcpServers = targetMcpServersFilter
204
- )
205
-
206
- options.systemPrompt = systemPromptParts.join('\n\n')
207
- return [
208
- {
209
- rules,
210
- targetSkills,
211
- entities,
212
- skills,
213
- specs,
214
- targetBody
215
- },
216
- options
217
- ] as const
218
- }
@@ -1,2 +0,0 @@
1
- export { generateAdapterQueryOptions } from './generate-adapter-query-options'
2
- export { run } from './run'
@@ -1,68 +0,0 @@
1
- import process from 'node:process'
2
-
3
- import { loadConfig } from '@vibe-forge/core'
4
- import type { AdapterCtx, AdapterQueryOptions } from '@vibe-forge/core/adapter'
5
- import { getCache, setCache } from '@vibe-forge/core/utils/cache'
6
- import { createLogger } from '@vibe-forge/core/utils/create-logger'
7
- import { uuid } from '@vibe-forge/core/utils/uuid'
8
-
9
- import { resolveServerLogLevel } from '#~/env.js'
10
-
11
- import type { RunTaskOptions } from './type'
12
-
13
- export const prepare = async (
14
- options: RunTaskOptions,
15
- adapterOptions: AdapterQueryOptions
16
- ) => {
17
- const cwd = options.cwd ?? process.env.__VF_PROJECT_WORKSPACE_FOLDER__ ?? process.cwd()
18
-
19
- const {
20
- sessionId = uuid()
21
- } = adapterOptions
22
- const {
23
- ctxId = process.env.__VF_PROJECT_AI_CTX_ID__ ?? sessionId,
24
- env: envFromOptions
25
- } = options
26
- const {
27
- __IS_LOADER_CLI__: _0,
28
- ...prevEnv
29
- } = {
30
- ...process.env,
31
- ...envFromOptions
32
- }
33
- const env: Record<string, string | null | undefined> = {
34
- ...prevEnv,
35
- __VF_PROJECT_AI_CTX_ID__: ctxId,
36
- __VF_PROJECT_AI_SESSION_ID__: sessionId,
37
- __VF_PROJECT_AI_RUN_TYPE__: adapterOptions.runtime,
38
- // 移除 NODE_OPTIONS 环境变量,防止干扰子进程的运行环境
39
- NODE_OPTIONS: undefined
40
- }
41
- const logger = createLogger(
42
- cwd,
43
- ctxId,
44
- sessionId,
45
- env?.LOG_PREFIX ?? '',
46
- resolveServerLogLevel(env)
47
- )
48
-
49
- const jsonVariables: Record<string, string | null | undefined> = {
50
- ...env,
51
- WORKSPACE_FOLDER: cwd,
52
- __VF_PROJECT_WORKSPACE_FOLDER__: cwd
53
- }
54
- const [config, userConfig] = await loadConfig({ jsonVariables })
55
- return [
56
- {
57
- ctxId,
58
- cwd,
59
- env,
60
- cache: {
61
- set: (key, value) => setCache(cwd, ctxId, sessionId, key, value),
62
- get: (key) => getCache(cwd, ctxId, sessionId, key)
63
- },
64
- logger,
65
- configs: [config, userConfig]
66
- } satisfies AdapterCtx
67
- ] as const
68
- }
@@ -1,191 +0,0 @@
1
- import type { AdapterCtx, AdapterOutputEvent, AdapterQueryOptions } from '#~/adapter/index.js'
2
- import { loadAdapter } from '#~/adapter/index.js'
3
- import type { ModelServiceConfig } from '#~/config.js'
4
- import { callHook } from '#~/hooks/call.js'
5
- import type { TaskDetail } from '#~/types.js'
6
-
7
- import { prepare } from './prepare'
8
- import type { RunTaskOptions } from './type'
9
-
10
- const normalizeNonEmptyString = (value: unknown) => (
11
- typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
12
- )
13
-
14
- const pickFirstNonEmptyString = (values: unknown[]) =>
15
- values
16
- .map(normalizeNonEmptyString)
17
- .find((value): value is string => value != null)
18
-
19
- const resolveQueryModel = (params: {
20
- config: AdapterCtx['configs'][0]
21
- userConfig: AdapterCtx['configs'][1]
22
- inputModel?: string
23
- }) => {
24
- const inputModel = normalizeNonEmptyString(params.inputModel)
25
- // User explicitly provided a model → pass through as-is.
26
- // The adapter decides CCR vs native based on whether it contains ",".
27
- if (inputModel != null) return inputModel
28
-
29
- // No explicit model → auto-resolve from modelServices config.
30
- // Produces "service,model" format when services are configured,
31
- // which signals the adapter to route through CCR.
32
- const mergedModelServices = {
33
- ...(params.config?.modelServices ?? {}),
34
- ...(params.userConfig?.modelServices ?? {})
35
- }
36
- const mergedDefaultModel = pickFirstNonEmptyString(
37
- [
38
- params.userConfig?.defaultModel,
39
- params.config?.defaultModel
40
- ]
41
- )
42
- const mergedDefaultModelService = pickFirstNonEmptyString(
43
- [
44
- params.userConfig?.defaultModelService,
45
- params.config?.defaultModelService
46
- ]
47
- )
48
-
49
- const serviceEntries = Object.entries(mergedModelServices)
50
- const modelToService = new Map<string, string>()
51
- const availableModels: string[] = []
52
- for (const [serviceKey, serviceValue] of serviceEntries) {
53
- const service = (serviceValue != null && typeof serviceValue === 'object')
54
- ? serviceValue as ModelServiceConfig
55
- : undefined
56
- const models = Array.isArray(service?.models)
57
- ? service?.models.filter(item => typeof item === 'string' && item.trim() !== '')
58
- : []
59
- for (const model of models) {
60
- if (!modelToService.has(model)) modelToService.set(model, serviceKey)
61
- availableModels.push(model)
62
- }
63
- }
64
-
65
- if (availableModels.length === 0) return undefined
66
-
67
- const resolveDefaultModel = () => {
68
- if (mergedDefaultModel && modelToService.has(mergedDefaultModel)) return mergedDefaultModel
69
- if (mergedDefaultModelService && mergedModelServices[mergedDefaultModelService]) {
70
- const service = mergedModelServices[mergedDefaultModelService] as ModelServiceConfig | undefined
71
- const models = Array.isArray(service?.models)
72
- ? service?.models.filter((item: unknown) => typeof item === 'string' && (item as string).trim() !== '')
73
- : []
74
- if (models.length > 0) return models[0]
75
- }
76
- return availableModels[0]
77
- }
78
-
79
- const resolvedModel = resolveDefaultModel()
80
- if (!resolvedModel) return undefined
81
-
82
- const resolvedService = modelToService.get(resolvedModel) ??
83
- mergedDefaultModelService ??
84
- serviceEntries[0]?.[0]
85
-
86
- return resolvedService ? `${resolvedService},${resolvedModel}` : resolvedModel
87
- }
88
-
89
- declare module '@vibe-forge/core' {
90
- interface Cache {
91
- base: Omit<AdapterCtx, 'logger' | 'cache'>
92
- detail: TaskDetail
93
- }
94
- }
95
-
96
- export const run = async (
97
- options: RunTaskOptions,
98
- adapterOptions: AdapterQueryOptions
99
- ) => {
100
- const [ctx] = await prepare(options, adapterOptions)
101
- const {
102
- configs: [config, userConfig]
103
- } = ctx
104
-
105
- const { logger, cache, ...base } = ctx
106
-
107
- await cache.set('base', base)
108
-
109
- const adapters = {
110
- ...config?.adapters,
111
- ...userConfig?.adapters
112
- }
113
- // dprint-ignore
114
- const adapterType =
115
- // 0. adapter from options
116
- options.adapter ??
117
- // 1. config default adapter
118
- config?.defaultAdapter ??
119
- // 2. user config default adapter
120
- userConfig?.defaultAdapter ??
121
- // 3. first adapter in config
122
- (() => {
123
- const adapterNames = Object.keys(adapters)
124
- if (adapterNames.length === 0) {
125
- throw new Error('No adapter found in config, please set adapters in config file')
126
- }
127
- return adapterNames[0]
128
- })()
129
-
130
- const originalOnEvent = adapterOptions.onEvent
131
- const wrappedOnEvent = (event: AdapterOutputEvent) => {
132
- if (event.type === 'init') {
133
- originalOnEvent({
134
- ...event,
135
- data: {
136
- ...event.data,
137
- adapter: adapterType
138
- }
139
- })
140
- return
141
- }
142
-
143
- if (event.type === 'exit') {
144
- const { data } = event
145
-
146
- void callHook('TaskStop', {
147
- adapter: adapterType,
148
- cwd: ctx.cwd,
149
- sessionId: adapterOptions.sessionId,
150
-
151
- options,
152
- adapterOptions,
153
-
154
- exitCode: data.exitCode,
155
- stderr: data.stderr
156
- }, ctx.env)
157
- .catch((e) => {
158
- logger.error('[Hook] TaskStop failed', e)
159
- })
160
- }
161
- originalOnEvent(event)
162
- }
163
-
164
- const adapter = await loadAdapter(adapterType)
165
- await adapter.init?.(ctx)
166
-
167
- const resolvedModel = resolveQueryModel({
168
- config,
169
- userConfig,
170
- inputModel: adapterOptions.model
171
- })
172
-
173
- await callHook('TaskStart', {
174
- adapter: adapterType,
175
- cwd: ctx.cwd,
176
- sessionId: adapterOptions.sessionId,
177
-
178
- options,
179
- adapterOptions
180
- }, ctx.env)
181
- const session = await adapter.query(
182
- ctx,
183
- {
184
- ...adapterOptions,
185
- model: resolvedModel,
186
- onEvent: wrappedOnEvent
187
- }
188
- )
189
-
190
- return { session, ctx }
191
- }
@@ -1,131 +0,0 @@
1
- import z from 'zod'
2
-
3
- export const TaskOptions = z.object({
4
- type: z
5
- .union([
6
- z.literal('entity'),
7
- // 基础库 API 能力开发
8
- z.literal('spec')
9
- ])
10
- .describe('任务模式'),
11
- name: z.string().describe('垂类知识库基准目标'),
12
- specParams: z.record(z.string()).describe('SPEC 执行时的参数').optional(),
13
- runtime: z
14
- .object({
15
- type: z
16
- .string()
17
- .describe('选择特定的 AI CLI 类型'),
18
- noDefaultSystemPrompt: z
19
- .boolean()
20
- .describe('是否禁用默认的系统提示')
21
- .optional()
22
- }),
23
- description: z
24
- .string()
25
- .describe('本次任务的描述,介绍关于本次任务需要进行的工作')
26
- .optional(),
27
- sessionId: z
28
- .string()
29
- .describe(
30
- '复用上次会话的历史消息作为本次任务的上下文。\n' +
31
- '- 通常如果某个任务在执行的时候出现了非预期的错误,那么你可以通过传入相同的 sessionID 来继续这个会话\n' +
32
- '- 如果有一个步骤需要间隔执行,比如说先执行任务 A 的 A-1,然后完成后执行 B 的 B-1,等 B-1 这个完成后回到 A-1 继续执行 A-2 时也可以使用'
33
- )
34
- .optional(),
35
- frontendTimeout: z
36
- .number()
37
- .describe(
38
- '前台等待时间的上限,单位秒,默认值为 8 分钟。' +
39
- '在超过这个时间后该任务会被转化为一个后台任务,并直接返回当前的输出以及 sessionId,你可以通过 sessionId 来继续这个任务,或者查询任务的状态'
40
- )
41
- .default(8 * 60)
42
- .optional(),
43
- defaultModel: z.string().describe('默认的模型').optional()
44
- })
45
-
46
- export type TaskOptions = z.infer<typeof TaskOptions>
47
-
48
- export const MCPRunTasksOptions = z.object({
49
- tasks: z
50
- .array(TaskOptions)
51
- .describe(
52
- '子任务列表。传递多个子任务时会并发执行多个,可用于优化整体任务效率。'
53
- ),
54
-
55
- bashDefaultTimeoutMs: z.number().optional(),
56
- bashMaxTimeoutMs: z.number().optional(),
57
- maxMcpOutputTokens: z.number().optional(),
58
- mcpTimeout: z.number().optional(),
59
- mcpToolTimeout: z.number().optional()
60
- })
61
-
62
- export type MCPRunTasksOptions = z.infer<typeof MCPRunTasksOptions>
63
-
64
- export const Options = z
65
- .object({
66
- taskId: z
67
- .string()
68
- .describe(
69
- '唯一 id,会用于关联多个任务相关信息。\n' +
70
- '如果是第一次执行,则不需要传入,会在返回值中自动生成一个,在下次创建或复用的时候必须传入该值以供记录相关上下文。\n' +
71
- '用户指定了 taskId,则以用户指定的 taskId 为准,在创建任务时则必须要指定对应的 taskId 参数。'
72
- )
73
- .optional()
74
- })
75
- .extend(MCPRunTasksOptions.shape)
76
-
77
- export type Options = z.infer<typeof Options>
78
-
79
- export const Entity = z.object({
80
- prompt: z
81
- .string()
82
- .describe('实体的描述,简单介绍一下当前实体的作用。')
83
- .optional(),
84
- promptPath: z
85
- .string()
86
- .describe(
87
- '实体的描述文件路径,文件内容为实体的描述。默认为当前目录下的 AGENTS.md 文件。'
88
- )
89
- .optional(),
90
- rules: z
91
- .array(
92
- z.union([
93
- z.string(),
94
- z.discriminatedUnion('type', [
95
- z.object({
96
- type: z.literal('local').optional(),
97
- path: z.string(),
98
- desc: z.string().optional()
99
- }),
100
- z.object({
101
- type: z.literal('remote'),
102
- tags: z.array(z.string()).describe('关键 tag').optional(),
103
- desc: z.string().describe('知识库的描述').optional()
104
- })
105
- ])
106
- ])
107
- )
108
- .optional()
109
- .describe('垂类 agent 的规则集合'),
110
- skills: z
111
- .object({
112
- type: z.union([z.literal('include'), z.literal('exclude')]),
113
- list: z.array(z.string()).describe('技能列表')
114
- })
115
- .optional(),
116
- mcpServers: z
117
- .object({
118
- include: z.array(z.string()).describe('包含的服务名称列表'),
119
- exclude: z.array(z.string()).describe('排除的服务名称列表')
120
- })
121
- .optional(),
122
- tools: z
123
- .object({
124
- include: z.array(z.string()).describe('包含的工具名称列表'),
125
- exclude: z.array(z.string()).describe('排除的工具名称列表')
126
- })
127
- .optional(),
128
- defaultModel: z.string().optional()
129
- })
130
-
131
- export type Entity = z.infer<typeof Entity>
@@ -1,6 +0,0 @@
1
- export interface RunTaskOptions {
2
- ctxId?: string
3
- adapter?: string
4
- env?: Record<string, string | undefined | null>
5
- cwd?: string
6
- }
package/src/hooks/call.ts DELETED
@@ -1,74 +0,0 @@
1
- import { Buffer } from 'node:buffer'
2
- import { spawn } from 'node:child_process'
3
- import path from 'node:path'
4
- import process from 'node:process'
5
-
6
- import type { HookInputs, HookOutputs } from './type'
7
-
8
- export type HookEventName = keyof HookInputs
9
-
10
- type HookInputPayload<K extends HookEventName> = Omit<HookInputs[K], 'hookEventName'>
11
-
12
- const pickHookEnv = (env: Record<string, unknown>): Record<string, string> => {
13
- const result: Record<string, string> = {}
14
- for (const [key, value] of Object.entries(env)) {
15
- if (typeof value === 'string') {
16
- result[key] = value
17
- }
18
- }
19
- return result
20
- }
21
-
22
- const resolveHookCliJs = () => {
23
- try {
24
- const pkgJsonPath = require.resolve('@vibe-forge/cli/package.json')
25
- return path.resolve(path.dirname(pkgJsonPath), 'call-hook.js')
26
- } catch (error) {
27
- throw new Error('Failed to resolve @vibe-forge/cli hook entry', { cause: error })
28
- }
29
- }
30
-
31
- export const callHook = async <K extends HookEventName>(
32
- hookEventName: K,
33
- input: HookInputPayload<K>,
34
- env: Record<string, unknown> = process.env
35
- ): Promise<HookOutputs[K]> => {
36
- const childEnv = pickHookEnv(env)
37
- const child = spawn(process.execPath, [resolveHookCliJs()], {
38
- cwd: typeof input.cwd === 'string' ? input.cwd : process.cwd(),
39
- env: childEnv,
40
- stdio: ['pipe', 'pipe', 'pipe']
41
- })
42
-
43
- const stdoutChunks: Buffer[] = []
44
- const stderrChunks: Buffer[] = []
45
-
46
- child.stdout.on('data', chunk => stdoutChunks.push(chunk))
47
- child.stderr.on('data', chunk => stderrChunks.push(chunk))
48
-
49
- const exitCode = await new Promise<number>((resolve, reject) => {
50
- child.once('error', reject)
51
- child.once('close', code => resolve(code ?? 0))
52
- child.stdin.end(JSON.stringify({
53
- ...input,
54
- hookEventName
55
- }))
56
- })
57
-
58
- const stdout = Buffer.concat(stdoutChunks).toString('utf-8').trim()
59
- const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
60
-
61
- if (exitCode !== 0) {
62
- throw new Error(`Failed to call hook: process exited with code ${exitCode}${stderr ? ` - ${stderr}` : ''}`)
63
- }
64
-
65
- if (stdout === '') {
66
- return { continue: true } as HookOutputs[K]
67
- }
68
-
69
- try {
70
- return JSON.parse(stdout) as HookOutputs[K]
71
- } catch (error) {
72
- throw new Error(`Failed to parse hook output: ${stdout}${stderr ? `\nstderr: ${stderr}` : ''}`, { cause: error })
73
- }
74
- }