@hunterzhu/pulse-cli 0.4.1 → 0.4.2

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/dist/bin.js CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
7
- import { ensurePulseUserConfig, expandHome, loadPulseConfig } from './config.js';
7
+ import { defaultPulseConfig, ensurePulseUserConfig, expandHome, loadPulseConfig } from './config.js';
8
8
  import { runInteractive } from './commands/interactive.js';
9
9
  import { runOneShot } from './commands/run.js';
10
10
  import { runDoctor } from './commands/doctor.js';
@@ -60,6 +60,12 @@ Options:
60
60
  --system-prompt-file <path> load custom system instructions from file
61
61
  --mock-response <text> deterministic response for local debugging
62
62
  --mock-task-assessment <json> deterministic verifier result for local debugging
63
+ --mock-stage-assessment <json> deterministic stage verifier results for local debugging
64
+ --mock-parallel-plan <json> deterministic task or read-only split plan for local debugging
65
+ --mock-tool-calls <json> deterministic tool calls for local debugging
66
+ --mock-after-tool-response <text> deterministic response after tool execution
67
+ --task-controller force the multi-stage task controller
68
+ --no-task-controller keep a single ReAct loop
63
69
  --live doctor: make one real provider request
64
70
  --no-color disable terminal styling
65
71
  --help, -h show this help
@@ -123,17 +129,37 @@ export async function hostOptions(parsed) {
123
129
  if (explicitConfig === undefined && process.env.PULSE_CONFIG === undefined) {
124
130
  await ensurePulseUserConfig();
125
131
  }
126
- const config = (await loadPulseConfig(requestedCwd ?? process.cwd(), explicitConfig, parsed.options['trust-workspace'] === true)).value;
127
- const requestedModel = option(parsed.options, 'model') ?? process.env.PULSE_MODEL ?? config.activeModel;
132
+ const loaded = (await loadPulseConfig(requestedCwd ?? process.cwd(), explicitConfig, parsed.options['trust-workspace'] === true)).value;
133
+ // A missing user config still has to answer usage errors and list sessions.
134
+ const config = loaded.activeModel || loaded.models || loaded.providers ? loaded : { ...defaultPulseConfig, ...loaded };
135
+ const mockResponse = option(parsed.options, 'mock-response');
136
+ const mockTaskAssessment = option(parsed.options, 'mock-task-assessment');
137
+ const mockStageAssessment = option(parsed.options, 'mock-stage-assessment');
138
+ const mockToolCallsOption = option(parsed.options, 'mock-tool-calls');
139
+ const mockAfterToolResponse = option(parsed.options, 'mock-after-tool-response');
140
+ const mockParallelPlanOption = option(parsed.options, 'mock-parallel-plan');
141
+ const hasMockDemand = mockResponse !== undefined || mockTaskAssessment !== undefined || mockStageAssessment !== undefined || mockToolCallsOption !== undefined || mockAfterToolResponse !== undefined || mockParallelPlanOption !== undefined;
142
+ const cliModel = option(parsed.options, 'model');
143
+ const requestedModel = cliModel ?? (hasMockDemand ? 'mock' : process.env.PULSE_MODEL ?? config.activeModel);
144
+ const effectiveModels = { ...(config.models ?? {}) };
145
+ const effectiveProviders = { ...(config.providers ?? {}) };
146
+ if (requestedModel === 'mock' || (hasMockDemand && cliModel === undefined)) {
147
+ if (!effectiveModels.mock) {
148
+ effectiveModels.mock = { displayName: 'mock', provider: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' };
149
+ }
150
+ if (!effectiveProviders.mock) {
151
+ effectiveProviders.mock = { provider: 'mock', name: 'Mock' };
152
+ }
153
+ }
128
154
  const configuredModelEntry = requestedModel === undefined
129
155
  ? undefined
130
- : Object.entries(config.models ?? {}).find(([name, item]) => name === requestedModel || item.displayName === requestedModel);
156
+ : Object.entries(effectiveModels).find(([name, item]) => name === requestedModel || item.displayName === requestedModel);
131
157
  if (!configuredModelEntry)
132
158
  throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel ?? '(missing)'}`);
133
159
  const [, modelSelection] = configuredModelEntry;
134
160
  const activeModelName = modelSelection.displayName;
135
161
  const providerName = modelSelection.provider;
136
- const profile = config.providers?.[providerName];
162
+ const profile = effectiveProviders[providerName];
137
163
  if (!profile)
138
164
  throw new Error(`MODEL_PROVIDER_NOT_FOUND:${providerName}`);
139
165
  const model = modelSelection.modelCode;
@@ -169,7 +195,7 @@ export async function hostOptions(parsed) {
169
195
  ...(configuredReasoningEffort === undefined ? {} : { reasoningEffort: configuredReasoningEffort }),
170
196
  ...(configuredToolChoice === undefined ? {} : { toolChoice: configuredToolChoice }),
171
197
  };
172
- const providerProfiles = Object.fromEntries(Object.entries(config.providers ?? {}).flatMap(([name, item]) => {
198
+ const providerProfiles = Object.fromEntries(Object.entries(effectiveProviders).flatMap(([name, item]) => {
173
199
  if (!item.provider)
174
200
  throw new Error(`PROVIDER_PROTOCOL_REQUIRED:${name}`);
175
201
  const adapterProvider = item.provider;
@@ -186,8 +212,8 @@ export async function hostOptions(parsed) {
186
212
  }]];
187
213
  }));
188
214
  const modelDisplayNames = new Set();
189
- const providerModels = Object.fromEntries(Object.entries(config.models ?? {}).flatMap(([name, item]) => {
190
- if (!config.providers?.[item.provider])
215
+ const providerModels = Object.fromEntries(Object.entries(effectiveModels).flatMap(([name, item]) => {
216
+ if (!effectiveProviders[item.provider])
191
217
  throw new Error(`MODEL_PROVIDER_NOT_FOUND:${item.provider}`);
192
218
  const displayName = item.displayName.trim();
193
219
  if (!displayName)
@@ -213,8 +239,6 @@ export async function hostOptions(parsed) {
213
239
  }
214
240
  const cwd = requestedCwd ?? expandHome(config.cwd);
215
241
  const dataDir = expandHome(option(parsed.options, 'data-dir') ?? process.env.PULSE_DATA_DIR ?? config.dataDir);
216
- const mockResponse = option(parsed.options, 'mock-response');
217
- const mockTaskAssessment = option(parsed.options, 'mock-task-assessment');
218
242
  let mockTaskAssessments;
219
243
  if (mockTaskAssessment !== undefined) {
220
244
  try {
@@ -227,6 +251,45 @@ export async function hostOptions(parsed) {
227
251
  throw new Error('INVALID_MOCK_TASK_ASSESSMENT: expected a non-empty JSON array of assessment objects');
228
252
  }
229
253
  }
254
+ else if (hasMockDemand) {
255
+ mockTaskAssessments = [{ status: 'accepted', criteria: [{ criterionId: 'criterion-1', status: 'passed', evidenceRefs: ['AUTO'], rationale: 'Accepted by mock verifier.' }] }];
256
+ }
257
+ let mockStageAssessments;
258
+ if (mockStageAssessment !== undefined) {
259
+ try {
260
+ const value = JSON.parse(mockStageAssessment);
261
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => !item || typeof item !== 'object' || Array.isArray(item)))
262
+ throw new Error('INVALID');
263
+ mockStageAssessments = value;
264
+ }
265
+ catch {
266
+ throw new Error('INVALID_MOCK_STAGE_ASSESSMENT: expected a non-empty JSON array of stage assessment objects');
267
+ }
268
+ }
269
+ let mockToolCalls;
270
+ if (mockToolCallsOption !== undefined) {
271
+ try {
272
+ const value = JSON.parse(mockToolCallsOption);
273
+ if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object' || Array.isArray(item)))
274
+ throw new Error('INVALID');
275
+ mockToolCalls = value;
276
+ }
277
+ catch {
278
+ throw new Error('INVALID_MOCK_TOOL_CALLS: expected a JSON array of tool call objects');
279
+ }
280
+ }
281
+ let mockParallelPlan;
282
+ if (mockParallelPlanOption !== undefined) {
283
+ try {
284
+ const value = JSON.parse(mockParallelPlanOption);
285
+ if (!value || typeof value !== 'object')
286
+ throw new Error('INVALID');
287
+ mockParallelPlan = value;
288
+ }
289
+ catch {
290
+ throw new Error('INVALID_MOCK_PARALLEL_PLAN: expected a JSON object');
291
+ }
292
+ }
230
293
  const requestedApprovalMode = option(parsed.options, 'approval-mode') ?? process.env.PULSE_APPROVAL_MODE;
231
294
  const approvalMode = requestedApprovalMode === 'read-only' || parsed.options['read-only'] === true
232
295
  ? 'read-only'
@@ -291,7 +354,9 @@ export async function hostOptions(parsed) {
291
354
  }
292
355
  }
293
356
  }
357
+ const explicitTaskController = parsed.options['task-controller'] === true ? true : parsed.options['no-task-controller'] === true ? false : mockParallelPlanOption !== undefined ? true : undefined;
294
358
  return {
359
+ ...(explicitTaskController === undefined ? {} : { taskController: explicitTaskController }),
295
360
  ...(cwd === undefined ? {} : { cwd }),
296
361
  ...(dataDir === undefined ? {} : { dataDir }),
297
362
  ...(systemPrompt && systemPrompt.trim().length > 0 ? { systemPrompt: systemPrompt.trim() } : {}),
@@ -307,6 +372,10 @@ export async function hostOptions(parsed) {
307
372
  activeModel: activeModelName,
308
373
  ...(mockResponse === undefined ? {} : { mockResponse }),
309
374
  ...(mockTaskAssessments === undefined ? {} : { mockTaskAssessments }),
375
+ ...(mockStageAssessments === undefined ? {} : { mockStageAssessments }),
376
+ ...(mockToolCalls === undefined ? {} : { mockToolCalls }),
377
+ ...(mockAfterToolResponse === undefined ? {} : { mockAfterToolResponse }),
378
+ ...(mockParallelPlan === undefined ? {} : { mockParallelPlan }),
310
379
  ...(approvalMode === undefined ? {} : { approvalMode }),
311
380
  ...(configuredMaxTurns === undefined ? {} : { maxTurns: configuredMaxTurns }),
312
381
  ...(configuredAutoCompactPercent === undefined ? {} : { autoCompactPercent: Math.min(90, configuredAutoCompactPercent) }),
@@ -345,21 +414,60 @@ export async function main() {
345
414
  return runMcpDoctor(options, parsed.positionals.slice(1));
346
415
  if (parsed.command === 'template')
347
416
  return runTemplateCommand(options, parsed.positionals, option(parsed.options, 'format') ?? 'text', version);
417
+ async function readPipedStdin() {
418
+ if (process.stdin.isTTY)
419
+ return '';
420
+ return new Promise((resolve) => {
421
+ let data = '';
422
+ let settled = false;
423
+ const finish = () => {
424
+ if (settled)
425
+ return;
426
+ settled = true;
427
+ clearTimeout(idle);
428
+ process.stdin.pause();
429
+ resolve(data.trim());
430
+ };
431
+ // An open stdin with no bytes must not block a command that already has a task.
432
+ const idle = setTimeout(finish, 250);
433
+ process.stdin.setEncoding('utf8');
434
+ process.stdin.on('data', (chunk) => {
435
+ data += chunk;
436
+ clearTimeout(idle);
437
+ });
438
+ process.stdin.on('end', finish);
439
+ process.stdin.on('error', finish);
440
+ if (process.stdin.readableEnded)
441
+ finish();
442
+ });
443
+ }
348
444
  if (parsed.command === 'run') {
349
- const task = parsed.positionals.join(' ').trim();
445
+ let task = parsed.positionals.join(' ').trim();
446
+ if (!task && !process.stdin.isTTY) {
447
+ task = await readPipedStdin();
448
+ }
449
+ else if (task && !process.stdin.isTTY) {
450
+ const piped = await readPipedStdin();
451
+ if (piped)
452
+ task = `${task}\n\n${piped}`;
453
+ }
350
454
  if (!task)
351
455
  throw new Error('TASK_REQUIRED');
352
456
  return runOneShot(options, task, option(parsed.options, 'format') ?? 'text', version);
353
457
  }
354
458
  if (parsed.command === 'resume') {
355
459
  const id = parsed.positionals.shift();
356
- const task = parsed.positionals.join(' ').trim();
460
+ let task = parsed.positionals.join(' ').trim();
461
+ if (!task && !process.stdin.isTTY) {
462
+ task = await readPipedStdin();
463
+ }
357
464
  if (!id)
358
465
  throw new Error('RESUME_REQUIRES_ID');
359
466
  return runResume(options, id, task || undefined, option(parsed.options, 'format') ?? 'text', version);
360
467
  }
361
468
  // 默认启动交互式 Ink 界面
362
- return runInteractive(options, undefined, undefined, version, parsed.options.resume === true);
469
+ const initialTask = parsed.positionals.join(' ').trim() || undefined;
470
+ return runInteractive(options, undefined, initialTask, version, parsed.options.resume === true);
363
471
  }
364
472
  if (process.argv[1] && isSameModulePath(process.argv[1], fileURLToPath(import.meta.url))) {
365
473
  main()
@@ -3,6 +3,7 @@ import { render } from 'ink';
3
3
  import { App } from '../components/App.js';
4
4
  import { createLocalHost } from '@hunterzhu/pulse-server';
5
5
  import { closePendingHosts } from '../hooks/useHost.js';
6
+ import { runOneShot } from './run.js';
6
7
  export async function findResumeConversation(options) {
7
8
  const host = createLocalHost(options);
8
9
  await host.init();
@@ -14,7 +15,14 @@ export async function findResumeConversation(options) {
14
15
  await host.close();
15
16
  }
16
17
  }
17
- export async function runInteractive(options, conversationId, initialTask, version = '0.1.4', resumeLatest = false) {
18
+ export async function runInteractive(options, conversationId, initialTask, version = '0.4.1', resumeLatest = false) {
19
+ if (!process.stdin.isTTY) {
20
+ if (initialTask) {
21
+ return runOneShot(options, initialTask, 'text', version);
22
+ }
23
+ process.stderr.write('交互模式需要 TTY 终端。非交互执行请使用 "pulse run <task>"。\n');
24
+ return 1;
25
+ }
18
26
  const selectedConversationId = resumeLatest
19
27
  ? await findResumeConversation(options)
20
28
  : conversationId;
@@ -20,8 +20,8 @@ function writeEvent(event, format) {
20
20
  process.stderr.write(`\n[错误] ${String(event.data ?? '')}\n`);
21
21
  }
22
22
  }
23
- export async function runResume(options, conversationId, task, format = 'text', version = '0.1.4') {
24
- // 如果在交互式终端且没有指定 jsonl,启动交互式 Ink 界面直接继续会话
23
+ export async function runResume(options, conversationId, task, format = 'text', version = '0.4.1') {
24
+ // 交互式终端进入 Ink 界面;jsonl 保持非交互输出
25
25
  if (process.stdout.isTTY && format !== 'jsonl') {
26
26
  return runInteractive(options, conversationId, task, version);
27
27
  }
@@ -1,5 +1,4 @@
1
1
  import { createLocalHost } from '@hunterzhu/pulse-server';
2
- import { runInteractive } from './interactive.js';
3
2
  import { bindRunSignals } from './signals.js';
4
3
  function writeEvent(event, format) {
5
4
  if (format === 'jsonl') {
@@ -20,11 +19,7 @@ function writeEvent(event, format) {
20
19
  process.stderr.write(`\n[错误] ${String(event.data ?? '')}\n`);
21
20
  }
22
21
  }
23
- export async function runOneShot(options, task, format = 'text', version = '0.1.4') {
24
- // 如果在交互式终端且没有指定 jsonl,启动交互式 Ink 界面直接执行任务
25
- if (process.stdout.isTTY && format !== 'jsonl') {
26
- return runInteractive(options, undefined, task, version);
27
- }
22
+ export async function runOneShot(options, task, format = 'text', version = '0.4.1') {
28
23
  const host = createLocalHost(options);
29
24
  let activeRun;
30
25
  const unbindSignals = bindRunSignals(host, () => activeRun);
@@ -76,6 +71,10 @@ export async function runOneShot(options, task, format = 'text', version = '0.1.
76
71
  return 3;
77
72
  return outcome.status === 'succeeded' && (!taskOutcome || taskOutcome.status === 'accepted') ? 0 : 1;
78
73
  }
74
+ catch (error) {
75
+ process.stderr.write(`\n[错误] ${error instanceof Error ? error.message : String(error)}\n`);
76
+ return 1;
77
+ }
79
78
  finally {
80
79
  unbindSignals();
81
80
  await host.close();
@@ -1,5 +1,27 @@
1
1
  // Generated from CHANGELOG.md and CHANGELOG.en.md by scripts/release/sync-cli-highlights.mjs. Do not edit manually.
2
2
  export const releaseHighlights = {
3
+ "0.4.2": [
4
+ {
5
+ "zh": "阶段验收要求每条引用都有效;涉及文件、命令或测试的阶段必须有成功的工具证据,纯写作阶段仍可凭候选结果通过。",
6
+ "en": "Stage acceptance requires every cited reference to be valid. Stages that touch files, commands, or tests need successful tool evidence, while pure writing stages can still pass on the candidate result."
7
+ },
8
+ {
9
+ "zh": "DeepSeek 不再把未命名 JSON 当成文件写入或命令执行;回复后的附加文本保持为正文,损坏的 DSML 参数按截断处理。",
10
+ "en": "DeepSeek recovery no longer treats unnamed JSON as a file write or shell command. Trailing text stays text, and invalid DSML parameters are treated as truncation."
11
+ },
12
+ {
13
+ "zh": "暂存写入和提交会拒绝 `.pulse` 路径;列出不存在的目录会返回 ENOENT,已存在的空目录仍返回空列表。",
14
+ "en": "Staged writes and commits reject `.pulse` paths. Listing a missing directory returns ENOENT, while an existing empty directory still returns an empty list."
15
+ },
16
+ {
17
+ "zh": "命令拆分能识别引号;通过 `sh -c`、`bash -c` 等启动器执行的命令会被拒绝。",
18
+ "en": "Command splitting respects quotes. Commands launched through shells such as `sh -c` or `bash -c` are rejected."
19
+ },
20
+ {
21
+ "zh": "`pulse resume` 在终端重新进入交互界面;已有任务的非交互运行不再因空闲标准输入一直等待,显式 mock 参数可以覆盖 `PULSE_MODEL`。",
22
+ "en": "`pulse resume` returns to the interactive screen on a terminal. A non-interactive run that already has a task no longer waits on idle stdin, and explicit mock flags can override `PULSE_MODEL`."
23
+ }
24
+ ],
3
25
  "0.4.1": [
4
26
  {
5
27
  "zh": "新增按需发现与调用 Skill 的能力,减少无关指令进入任务上下文。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "A local-first AI task assistant for coding, research, and file workflows.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "registry": "https://registry.npmjs.org"
37
37
  },
38
38
  "dependencies": {
39
- "@hunterzhu/pulse-server": "0.4.1",
39
+ "@hunterzhu/pulse-server": "0.4.2",
40
40
  "chalk": "^5.4.1",
41
41
  "highlight.js": "^11.10.0",
42
42
  "ink": "^7.1.1",