@hunterzhu/pulse-cli 0.4.0 → 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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @hunterzhu/pulse-cli
2
2
 
3
+ [简体中文](https://github.com/zhuhengtan/Pulse/blob/main/packages/cli/README.zh-CN.md)
4
+
5
+ [Changelog](https://github.com/zhuhengtan/Pulse/blob/main/CHANGELOG.en.md)
6
+
3
7
  **A neural-signal like AI assistant for coding, research and file tasks.**
4
8
 
5
9
  Pulse runs in your project folder, keeps conversations on your machine, and can resume unfinished work. Requires Node.js 22+.
@@ -9,7 +13,7 @@ Pulse runs in your project folder, keeps conversations on your machine, and can
9
13
  Install the CLI globally so the `pulse` command is on your `PATH`:
10
14
 
11
15
  ```bash
12
- npm install -g @hunterzhu/pulse-cli
16
+ npm install -g @hunterzhu/pulse-cli --foreground-scripts
13
17
  pulse --version
14
18
  ```
15
19
 
@@ -49,6 +53,10 @@ pulse --read-only
49
53
 
50
54
  See `pulse --help` for all options.
51
55
 
56
+ ## License
57
+
58
+ This package is licensed under [PolyForm Noncommercial 1.0.0](./LICENSE). Commercial use requires separate permission. This is a source-available noncommercial license, not an OSI-approved open-source license.
59
+
52
60
  ## Links
53
61
 
54
62
  - [Full CLI configuration guide](https://github.com/zhuhengtan/Pulse/blob/main/docs/cli-config.md)
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';
@@ -16,6 +16,7 @@ import { runServiceCommand } from './commands/service.js';
16
16
  import { runTemplateCommand } from './commands/template.js';
17
17
  import { runMcpDoctor } from './commands/mcp.js';
18
18
  import { isSameModulePath } from './utils/is-main-module.js';
19
+ import { formatVersionOutput, shouldUseColor } from './presentation.js';
19
20
  const packageManifest = createRequire(import.meta.url)('../package.json');
20
21
  export const version = packageManifest.version;
21
22
  process.stdout.on('error', (error) => {
@@ -59,6 +60,12 @@ Options:
59
60
  --system-prompt-file <path> load custom system instructions from file
60
61
  --mock-response <text> deterministic response for local debugging
61
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
62
69
  --live doctor: make one real provider request
63
70
  --no-color disable terminal styling
64
71
  --help, -h show this help
@@ -122,17 +129,37 @@ export async function hostOptions(parsed) {
122
129
  if (explicitConfig === undefined && process.env.PULSE_CONFIG === undefined) {
123
130
  await ensurePulseUserConfig();
124
131
  }
125
- const config = (await loadPulseConfig(requestedCwd ?? process.cwd(), explicitConfig, parsed.options['trust-workspace'] === true)).value;
126
- 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
+ }
127
154
  const configuredModelEntry = requestedModel === undefined
128
155
  ? undefined
129
- : Object.entries(config.models ?? {}).find(([name, item]) => name === requestedModel || item.displayName === requestedModel);
156
+ : Object.entries(effectiveModels).find(([name, item]) => name === requestedModel || item.displayName === requestedModel);
130
157
  if (!configuredModelEntry)
131
158
  throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel ?? '(missing)'}`);
132
159
  const [, modelSelection] = configuredModelEntry;
133
160
  const activeModelName = modelSelection.displayName;
134
161
  const providerName = modelSelection.provider;
135
- const profile = config.providers?.[providerName];
162
+ const profile = effectiveProviders[providerName];
136
163
  if (!profile)
137
164
  throw new Error(`MODEL_PROVIDER_NOT_FOUND:${providerName}`);
138
165
  const model = modelSelection.modelCode;
@@ -168,7 +195,7 @@ export async function hostOptions(parsed) {
168
195
  ...(configuredReasoningEffort === undefined ? {} : { reasoningEffort: configuredReasoningEffort }),
169
196
  ...(configuredToolChoice === undefined ? {} : { toolChoice: configuredToolChoice }),
170
197
  };
171
- const providerProfiles = Object.fromEntries(Object.entries(config.providers ?? {}).flatMap(([name, item]) => {
198
+ const providerProfiles = Object.fromEntries(Object.entries(effectiveProviders).flatMap(([name, item]) => {
172
199
  if (!item.provider)
173
200
  throw new Error(`PROVIDER_PROTOCOL_REQUIRED:${name}`);
174
201
  const adapterProvider = item.provider;
@@ -185,8 +212,8 @@ export async function hostOptions(parsed) {
185
212
  }]];
186
213
  }));
187
214
  const modelDisplayNames = new Set();
188
- const providerModels = Object.fromEntries(Object.entries(config.models ?? {}).flatMap(([name, item]) => {
189
- if (!config.providers?.[item.provider])
215
+ const providerModels = Object.fromEntries(Object.entries(effectiveModels).flatMap(([name, item]) => {
216
+ if (!effectiveProviders[item.provider])
190
217
  throw new Error(`MODEL_PROVIDER_NOT_FOUND:${item.provider}`);
191
218
  const displayName = item.displayName.trim();
192
219
  if (!displayName)
@@ -212,8 +239,6 @@ export async function hostOptions(parsed) {
212
239
  }
213
240
  const cwd = requestedCwd ?? expandHome(config.cwd);
214
241
  const dataDir = expandHome(option(parsed.options, 'data-dir') ?? process.env.PULSE_DATA_DIR ?? config.dataDir);
215
- const mockResponse = option(parsed.options, 'mock-response');
216
- const mockTaskAssessment = option(parsed.options, 'mock-task-assessment');
217
242
  let mockTaskAssessments;
218
243
  if (mockTaskAssessment !== undefined) {
219
244
  try {
@@ -226,6 +251,45 @@ export async function hostOptions(parsed) {
226
251
  throw new Error('INVALID_MOCK_TASK_ASSESSMENT: expected a non-empty JSON array of assessment objects');
227
252
  }
228
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
+ }
229
293
  const requestedApprovalMode = option(parsed.options, 'approval-mode') ?? process.env.PULSE_APPROVAL_MODE;
230
294
  const approvalMode = requestedApprovalMode === 'read-only' || parsed.options['read-only'] === true
231
295
  ? 'read-only'
@@ -290,7 +354,9 @@ export async function hostOptions(parsed) {
290
354
  }
291
355
  }
292
356
  }
357
+ const explicitTaskController = parsed.options['task-controller'] === true ? true : parsed.options['no-task-controller'] === true ? false : mockParallelPlanOption !== undefined ? true : undefined;
293
358
  return {
359
+ ...(explicitTaskController === undefined ? {} : { taskController: explicitTaskController }),
294
360
  ...(cwd === undefined ? {} : { cwd }),
295
361
  ...(dataDir === undefined ? {} : { dataDir }),
296
362
  ...(systemPrompt && systemPrompt.trim().length > 0 ? { systemPrompt: systemPrompt.trim() } : {}),
@@ -301,11 +367,15 @@ export async function hostOptions(parsed) {
301
367
  ...(taskRouting === undefined ? {} : { taskRouting }),
302
368
  capabilityPacks,
303
369
  enabledCapabilityPacks,
304
- capabilityConfig: { skills: skillNames },
370
+ capabilityConfig: capabilitySettings.skills === undefined ? {} : { skills: skillNames },
305
371
  activeProviderCode: providerName,
306
372
  activeModel: activeModelName,
307
373
  ...(mockResponse === undefined ? {} : { mockResponse }),
308
374
  ...(mockTaskAssessments === undefined ? {} : { mockTaskAssessments }),
375
+ ...(mockStageAssessments === undefined ? {} : { mockStageAssessments }),
376
+ ...(mockToolCalls === undefined ? {} : { mockToolCalls }),
377
+ ...(mockAfterToolResponse === undefined ? {} : { mockAfterToolResponse }),
378
+ ...(mockParallelPlan === undefined ? {} : { mockParallelPlan }),
309
379
  ...(approvalMode === undefined ? {} : { approvalMode }),
310
380
  ...(configuredMaxTurns === undefined ? {} : { maxTurns: configuredMaxTurns }),
311
381
  ...(configuredAutoCompactPercent === undefined ? {} : { autoCompactPercent: Math.min(90, configuredAutoCompactPercent) }),
@@ -320,7 +390,9 @@ export async function main() {
320
390
  return 0;
321
391
  }
322
392
  if (parsed.options.version || parsed.options.v) {
323
- process.stdout.write(`${version}\n`);
393
+ process.stdout.write(formatVersionOutput(version, {
394
+ color: parsed.options['no-color'] !== true && shouldUseColor(process.env, process.stdout.isTTY === true),
395
+ }));
324
396
  return 0;
325
397
  }
326
398
  if (parsed.command === 'setup') {
@@ -342,21 +414,60 @@ export async function main() {
342
414
  return runMcpDoctor(options, parsed.positionals.slice(1));
343
415
  if (parsed.command === 'template')
344
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
+ }
345
444
  if (parsed.command === 'run') {
346
- 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
+ }
347
454
  if (!task)
348
455
  throw new Error('TASK_REQUIRED');
349
456
  return runOneShot(options, task, option(parsed.options, 'format') ?? 'text', version);
350
457
  }
351
458
  if (parsed.command === 'resume') {
352
459
  const id = parsed.positionals.shift();
353
- const task = parsed.positionals.join(' ').trim();
460
+ let task = parsed.positionals.join(' ').trim();
461
+ if (!task && !process.stdin.isTTY) {
462
+ task = await readPipedStdin();
463
+ }
354
464
  if (!id)
355
465
  throw new Error('RESUME_REQUIRES_ID');
356
466
  return runResume(options, id, task || undefined, option(parsed.options, 'format') ?? 'text', version);
357
467
  }
358
468
  // 默认启动交互式 Ink 界面
359
- 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);
360
471
  }
361
472
  if (process.argv[1] && isSameModulePath(process.argv[1], fileURLToPath(import.meta.url))) {
362
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();
@@ -18,11 +18,14 @@ import { useTokenStats } from '../hooks/useTokenStats.js';
18
18
  import { useSlashCommands } from '../hooks/useSlashCommands.js';
19
19
  import { copyToClipboard, isCopyShortcut } from '../utils/clipboard.js';
20
20
  import { emptyInputHistory, navigateInputHistory, rememberInput } from '../utils/inputHistory.js';
21
+ import { skillSuggestions } from '../utils/slashCompletion.js';
21
22
  export function App({ hostOptions, conversationId: initialConversationId, initialTask, resumeOnStart = false, version, }) {
22
23
  const { exit } = useApp();
23
24
  const { stdout } = useStdout();
24
25
  const [terminalRows, setTerminalRows] = useState(stdout.rows || 24);
25
26
  useEffect(() => { const resize = () => setTerminalRows(stdout.rows || 24); stdout.on('resize', resize); return () => { stdout.off('resize', resize); }; }, [stdout]);
27
+ const [skillNames, setSkillNames] = useState([]);
28
+ const [skillError, setSkillError] = useState(null);
26
29
  const [mode, setMode] = useState('chat');
27
30
  const [mouseEnabled, setMouseEnabled] = useState(true);
28
31
  const [selectedText, setSelectedText] = useState('');
@@ -38,6 +41,11 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
38
41
  const startedTask = useRef(false);
39
42
  const startedResume = useRef(false);
40
43
  const { host, error: hostError, ready: hostReady } = useHost(hostOptions);
44
+ const refreshSkills = useCallback(() => {
45
+ if (host)
46
+ void host.listSkills().then(names => { setSkillNames(names); setSkillError(null); }, error => setSkillError(String(error)));
47
+ }, [host]);
48
+ useEffect(refreshSkills, [refreshSkills]);
41
49
  const { conversation, messages, addUserMessage, addAssistantMessage, clearMessages, switchConversation, newConversation, createConversation, error: conversationError, } = useConversation({ host, conversationId: initialConversationId });
42
50
  const { isRunning, error: runError, approvalRequest, askRequest, lanes, approvalSubmitting, sendMessage, resumeActive, approveAction, replyAsk, cancelRun, } = useRun({
43
51
  host,
@@ -127,7 +135,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
127
135
  await sendMessage(initialTask, target.id);
128
136
  })();
129
137
  }, [hostReady, conversation, initialTask, isRunning, addUserMessage, addAssistantMessage, createConversation, sendMessage]);
130
- const { executeCommand, isSlashCommand } = useSlashCommands({
138
+ const { commands, executeCommand, isSlashCommand } = useSlashCommands({
131
139
  onMouse: (arg) => setMouseEnabled((current) => arg === 'off' ? false : arg === 'on' ? true : !current),
132
140
  onCopy: async () => {
133
141
  if (selectedText) {
@@ -455,6 +463,31 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
455
463
  if (isSlashCommand(text)) {
456
464
  const handled = await executeCommand(text);
457
465
  if (!handled) {
466
+ const token = text.split(/\s+/)[0].slice(1).replace(/^skill:/, '');
467
+ let currentSkills = skillNames;
468
+ try {
469
+ if (host) {
470
+ currentSkills = await host.listSkills();
471
+ setSkillNames(currentSkills);
472
+ setSkillError(null);
473
+ }
474
+ }
475
+ catch (error) {
476
+ setSkillError(String(error));
477
+ return;
478
+ }
479
+ if (currentSkills.includes(token)) {
480
+ if (isRunning) {
481
+ addAssistantMessage({ id: `skill-busy-${Date.now()}`, role: 'system', text: '请等当前任务完成,或使用 /cancel 后再调用技能。', createdAt: new Date().toISOString() });
482
+ return;
483
+ }
484
+ const target = conversation ?? await createConversation();
485
+ if (!target)
486
+ return;
487
+ addUserMessage(text);
488
+ await sendMessage(text, target.id);
489
+ return;
490
+ }
458
491
  addAssistantMessage({
459
492
  id: `command-${Date.now()}`,
460
493
  role: 'system',
@@ -470,7 +503,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
470
503
  addUserMessage(text);
471
504
  await sendMessage(text, target.id);
472
505
  }
473
- }, [isSlashCommand, executeCommand, conversation, createConversation, addUserMessage, sendMessage]);
506
+ }, [isSlashCommand, executeCommand, conversation, createConversation, addUserMessage, sendMessage, skillNames, isRunning, addAssistantMessage, host]);
474
507
  useInput((input, key) => {
475
508
  if (isCopyShortcut(input, key)) {
476
509
  if (selectedText)
@@ -541,6 +574,6 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
541
574
  }, onInput: () => {
542
575
  setApprovalChoiceFocused(false);
543
576
  setApprovalInputMode(true);
544
- } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsx(Box, { marginTop: 1, children: _jsx(InputArea, { onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' }) }))] }));
577
+ } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [skillError && _jsxs(Text, { color: "red", children: ["\u6280\u80FD\u7D22\u5F15\u5931\u8D25\uFF1A", skillError] }), _jsx(InputArea, { suggestions: [...commands, ...skillSuggestions(skillNames, commands.flatMap(command => [command, ...(command.aliases ?? []).map(name => ({ name, description: command.description }))]))], onSearchSkills: refreshSkills, onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' })] }))] }));
545
578
  }
546
579
  export default App;
@@ -38,6 +38,7 @@ const COMMANDS = [
38
38
  { name: '/model [name]', desc: '切换模型' },
39
39
  { name: '/thinking [level]', desc: '设置模型思考深度 (low/medium/high/off)' },
40
40
  { name: '/compact', desc: '手动压缩历史;默认在上下文 90% 时自动压缩' },
41
+ { name: '/skill:名称 任务', desc: '按需加载技能;输入 / 搜索,↑↓ 选择,Tab 补全' },
41
42
  { name: '/config', desc: '查看当前运行时配置' },
42
43
  ],
43
44
  },
@@ -1,4 +1,7 @@
1
+ import { type SlashSuggestion } from '../utils/slashCompletion.js';
1
2
  interface Props {
3
+ suggestions?: SlashSuggestion[];
4
+ onSearchSkills?: () => void;
2
5
  onSubmit: (text: string) => void;
3
6
  onRememberInput: (text: string) => void;
4
7
  onNavigateHistory: (direction: 'up' | 'down', currentValue: string) => string | undefined;
@@ -6,5 +9,5 @@ interface Props {
6
9
  focus?: boolean;
7
10
  placeholder?: string;
8
11
  }
9
- export declare function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus, placeholder }: Props): import("react").JSX.Element;
12
+ export declare function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus, placeholder, suggestions, onSearchSkills }: Props): import("react").JSX.Element;
10
13
  export {};
@@ -2,15 +2,44 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import TextInput from './TextInput.js';
4
4
  import { useState } from 'react';
5
+ import { searchSlashSuggestions } from '../utils/slashCompletion.js';
5
6
  import { theme } from '../theme.js';
6
- export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus = true, placeholder }) {
7
+ export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus = true, placeholder, suggestions = [], onSearchSkills }) {
7
8
  const [value, setValue] = useState('');
9
+ const [completionRevision, setCompletionRevision] = useState(0);
10
+ const [selected, setSelected] = useState(0);
11
+ const [dismissed, setDismissed] = useState(false);
12
+ const matches = dismissed ? [] : searchSlashSuggestions(value, suggestions);
13
+ const chosen = Math.min(selected, Math.max(0, matches.length - 1));
14
+ const changeValue = (next) => {
15
+ if (next.startsWith('/') && !value.startsWith('/'))
16
+ onSearchSkills?.();
17
+ setSelected(0);
18
+ setDismissed(false);
19
+ setValue(next);
20
+ };
8
21
  const [accumulatedLines, setAccumulatedLines] = useState([]);
9
22
  useInput((input, key) => {
10
23
  if (disabled || !focus)
11
24
  return;
12
25
  if (key.ctrl || key.meta || key.pageUp || key.pageDown)
13
26
  return;
27
+ if (matches.length && !accumulatedLines.length) {
28
+ if (key.escape) {
29
+ setDismissed(true);
30
+ return;
31
+ }
32
+ if (key.upArrow || key.downArrow) {
33
+ setSelected(Math.max(0, Math.min(matches.length - 1, chosen + (key.upArrow ? -1 : 1))));
34
+ return;
35
+ }
36
+ if (key.tab) {
37
+ setValue(`${matches[chosen].name} `);
38
+ setCompletionRevision(revision => revision + 1);
39
+ setSelected(0);
40
+ return;
41
+ }
42
+ }
14
43
  if (key.return) {
15
44
  if (key.shift) {
16
45
  setAccumulatedLines((current) => [...current, value]);
@@ -35,7 +64,7 @@ export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabl
35
64
  const restoreInput = (text) => {
36
65
  const lines = text.split('\n');
37
66
  setAccumulatedLines(lines.slice(0, -1));
38
- setValue(lines.at(-1) ?? '');
67
+ changeValue(lines.at(-1) ?? '');
39
68
  };
40
69
  const handleSubmit = (text) => {
41
70
  if (text.endsWith('\\')) {
@@ -56,5 +85,5 @@ export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabl
56
85
  };
57
86
  const promptChar = accumulatedLines.length > 0 ? '…' : '›';
58
87
  const promptColor = disabled ? theme.dim : theme.primary;
59
- return (_jsxs(Box, { flexDirection: "column", children: [accumulatedLines.map((line, idx) => (_jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), _jsx(Text, { children: line })] }, idx))), _jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), disabled ? (_jsx(Text, { color: theme.dim, children: placeholder || '处理中...' })) : (_jsx(TextInput, { focus: focus, value: value, onChange: setValue, placeholder: placeholder || '' }))] })] }));
88
+ return (_jsxs(Box, { flexDirection: "column", children: [matches.length > 0 && accumulatedLines.length === 0 && (_jsxs(Box, { flexDirection: "column", children: [matches.slice(Math.max(0, chosen - 5), Math.max(0, chosen - 5) + 6).map(item => (_jsxs(Text, { color: item === matches[chosen] ? theme.primary : theme.dim, children: [item === matches[chosen] ? '› ' : ' ', item.name, " ", item.description] }, item.name))), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 \u9009\u62E9 \u00B7 Tab \u8865\u5168 \u00B7 \u8F93\u5165\u4EFB\u52A1\u540E Enter \u53D1\u9001 \u00B7 Esc \u6536\u8D77\uFF08", matches.length, " \u9879\uFF09"] })] })), accumulatedLines.map((line, idx) => (_jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), _jsx(Text, { children: line })] }, idx))), _jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), disabled ? (_jsx(Text, { color: theme.dim, children: placeholder || '处理中...' })) : (_jsx(TextInput, { focus: focus, value: value, onChange: changeValue, placeholder: placeholder || '' }, completionRevision))] })] }));
60
89
  }
package/dist/config.d.ts CHANGED
@@ -47,7 +47,7 @@ export interface PulseCliMcpServer {
47
47
  export interface PulseCliCapabilities {
48
48
  /** IDs must be explicitly enabled; workspace config cannot enable host extensions. */
49
49
  enabled?: string[];
50
- /** Names of host-installed skills to load as untrusted instructions. */
50
+ /** Optional discovery allow-list of skill directory names; omitted discovers all. Bodies load only on invocation. */
51
51
  skills?: string[];
52
52
  /** Additional absolute roots explicitly trusted by the user for installed skills. */
53
53
  trustedSkillRoots?: string[];
@@ -0,0 +1,11 @@
1
+ export interface PresentationOptions {
2
+ color?: boolean;
3
+ }
4
+ export declare function shouldUseColor(env: NodeJS.ProcessEnv, isTTY: boolean): boolean;
5
+ export declare function formatVersionOutput(version: string, options?: PresentationOptions): string;
6
+ export interface InstallWelcomeOptions extends PresentationOptions {
7
+ version: string;
8
+ configPath?: string;
9
+ error?: string;
10
+ }
11
+ export declare function formatInstallWelcome(options: InstallWelcomeOptions): string;
@@ -0,0 +1,64 @@
1
+ import { releaseHighlights } from './release-highlights.generated.js';
2
+ const bannerLines = [
3
+ '██████╗ ██╗ ██╗██╗ ███████╗███████╗',
4
+ '██╔══██╗██║ ██║██║ ██╔════╝██╔════╝',
5
+ '██████╔╝██║ ██║██║ ███████╗█████╗ ',
6
+ '██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝ ',
7
+ '██║ ╚██████╔╝███████╗███████║███████╗',
8
+ '╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝',
9
+ ];
10
+ function paint(code, enabled, value) {
11
+ return enabled ? `\u001b[${code}m${value}\u001b[0m` : value;
12
+ }
13
+ export function shouldUseColor(env, isTTY) {
14
+ return env.NO_COLOR === undefined && env.FORCE_COLOR !== '0' && (isTTY || env.FORCE_COLOR !== undefined);
15
+ }
16
+ function banner(color) {
17
+ return bannerLines.map((line) => paint('36', color, line)).join('\n');
18
+ }
19
+ export function formatVersionOutput(version, options = {}) {
20
+ const color = options.color === true;
21
+ const highlights = releaseHighlights[version];
22
+ const lines = [
23
+ banner(color),
24
+ '',
25
+ ` ${paint('1', color, 'Pulse')} ${paint('2', color, `v${version}`)}`,
26
+ '',
27
+ ` ${paint('1', color, '本版本亮点 / What’s new in this version')}`,
28
+ ];
29
+ if (highlights?.length) {
30
+ for (const item of highlights)
31
+ lines.push(` • ${item.zh}`, ` ${item.en}`);
32
+ }
33
+ else {
34
+ lines.push(' 暂无此版本的更新亮点记录。', ' No release highlights are available for this version yet.');
35
+ }
36
+ lines.push('');
37
+ return `${lines.join('\n')}\n`;
38
+ }
39
+ export function formatInstallWelcome(options) {
40
+ const { color = false, version, configPath, error } = options;
41
+ const cyan = (value) => paint('36', color, value);
42
+ const dim = (value) => paint('2', color, value);
43
+ const lines = [
44
+ '',
45
+ banner(color),
46
+ '',
47
+ ` ${paint('1', color, 'Pulse')} v${version} — 面向编程、研究与文件工作的 AI 助手`,
48
+ ` ${paint('1', color, 'Pulse')} — an AI assistant for coding, research, and file tasks`,
49
+ '',
50
+ ` ${paint('1', color, '开始使用 / Get started:')}`,
51
+ ` ${cyan('cd project_dir')}`,
52
+ ` ${cyan('pulse')}`,
53
+ '',
54
+ ];
55
+ if (configPath) {
56
+ lines.push(` ${dim('配置已就绪 / Configuration ready at:')}`, ` ${configPath}`);
57
+ }
58
+ else {
59
+ lines.push(` ${dim('配置创建失败 / Configuration could not be created:')} ${error ?? 'Unknown error'}`);
60
+ lines.push(` ${dim('稍后运行 "pulse setup" 创建配置。 / Run "pulse setup" later to create it.')}`);
61
+ }
62
+ lines.push('');
63
+ return `${lines.join('\n')}\n`;
64
+ }
@@ -0,0 +1,4 @@
1
+ export declare const releaseHighlights: Record<string, Array<{
2
+ zh: string;
3
+ en: string;
4
+ }>>;
@@ -0,0 +1,139 @@
1
+ // Generated from CHANGELOG.md and CHANGELOG.en.md by scripts/release/sync-cli-highlights.mjs. Do not edit manually.
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
+ ],
25
+ "0.4.1": [
26
+ {
27
+ "zh": "新增按需发现与调用 Skill 的能力,减少无关指令进入任务上下文。",
28
+ "en": "Added on-demand Skill discovery and invocation to keep unrelated instructions out of task context."
29
+ },
30
+ {
31
+ "zh": "美化安装欢迎界面和版本页,提供中英双语介绍及版本亮点。",
32
+ "en": "Polished the installation welcome screen and version page with bilingual product information and release highlights."
33
+ },
34
+ {
35
+ "zh": "提供中英文分离的更新日志和双语用户文档,并校验版本亮点来源。",
36
+ "en": "Added separate Chinese and English changelogs and bilingual user documentation, with validation for version highlights."
37
+ },
38
+ {
39
+ "zh": "为全部五个 npm 包采用 PolyForm Noncommercial 1.0.0,并随包提供许可证说明。",
40
+ "en": "Licensed all five npm packages under PolyForm Noncommercial 1.0.0 and included package-level license notices."
41
+ }
42
+ ],
43
+ "0.4.0": [
44
+ {
45
+ "zh": "默认使用异步并行 event loop,让模型请求、工具调用等副作用并发推进。",
46
+ "en": "Uses an asynchronous, parallel event loop by default so model requests, tool calls, and other effects can progress concurrently."
47
+ }
48
+ ],
49
+ "0.3.0": [
50
+ {
51
+ "zh": "增强 CLI 交互体验,支持内容选择与复制、推理用量归因、任务进度流式展示和可恢复会话。",
52
+ "en": "Improved CLI interaction with text selection and copying, reasoning-usage attribution, streamed task progress, and resumable sessions."
53
+ }
54
+ ],
55
+ "0.2.1": [
56
+ {
57
+ "zh": "增加安装后的欢迎界面,并完善跨平台本地与云端 CI 验证入口。",
58
+ "en": "Added the post-install welcome screen and improved the shared cross-platform local and cloud CI entry point."
59
+ }
60
+ ],
61
+ "0.2.0": [
62
+ {
63
+ "zh": "扩展 CLI 与 Host 能力,完善会话恢复、人机协作、工具调用和 Windows 沙箱及发布验证。",
64
+ "en": "Expanded CLI and Host capabilities with resumable sessions, human interaction, tool calls, and improved Windows sandbox and release validation."
65
+ }
66
+ ],
67
+ "0.1.11": [
68
+ {
69
+ "zh": "修复 Windows 下打包 CLI 入口加载问题,并改进跨平台安装与发布验证。",
70
+ "en": "Fixed loading the packaged CLI entry point on Windows and improved cross-platform installation and release validation."
71
+ }
72
+ ],
73
+ "0.1.10": [
74
+ {
75
+ "zh": "维护性版本发布;未单独记录用户可见更新。",
76
+ "en": "Maintenance release; no separate user-facing changes were recorded."
77
+ }
78
+ ],
79
+ "0.1.9": [
80
+ {
81
+ "zh": "维护性版本发布;未单独记录用户可见更新。",
82
+ "en": "Maintenance release; no separate user-facing changes were recorded."
83
+ }
84
+ ],
85
+ "0.1.8": [
86
+ {
87
+ "zh": "增加 npm 安装后的首次配置初始化流程。",
88
+ "en": "Added first-run configuration initialization after npm installation."
89
+ }
90
+ ],
91
+ "0.1.7": [
92
+ {
93
+ "zh": "增加完整屏幕 CLI 工作区、模型配置与会话交互能力。",
94
+ "en": "Added a full-screen CLI workspace, model configuration, and interactive session capabilities."
95
+ }
96
+ ],
97
+ "0.1.6": [
98
+ {
99
+ "zh": "增加人类输入仲裁和主动 CLI 交互,并强化上下文、持久化与流式工具调用。",
100
+ "en": "Added human-input arbitration and active CLI interaction, while strengthening context, persistence, and streamed tool calls."
101
+ }
102
+ ],
103
+ "0.1.5": [
104
+ {
105
+ "zh": "改进 Provider 工具名称兼容性。",
106
+ "en": "Improved compatibility of tool names across providers."
107
+ }
108
+ ],
109
+ "0.1.4": [
110
+ {
111
+ "zh": "修复 Provider 安全工具名称的适配问题。",
112
+ "en": "Fixed adapter handling for provider-safe tool names."
113
+ }
114
+ ],
115
+ "0.1.3": [
116
+ {
117
+ "zh": "完善 CLI 工具暴露、用户文件目录和 Provider 错误反馈。",
118
+ "en": "Improved CLI tool exposure, user-file locations, and provider error reporting."
119
+ }
120
+ ],
121
+ "0.1.2": [
122
+ {
123
+ "zh": "修复 CLI 显示已安装包版本的问题。",
124
+ "en": "Fixed CLI reporting of the installed package version."
125
+ }
126
+ ],
127
+ "0.1.1": [
128
+ {
129
+ "zh": "完善首次运行配置创建及基础 npm 工作区使用体验。",
130
+ "en": "Improved first-run configuration setup and basic npm workspace usage."
131
+ }
132
+ ],
133
+ "0.1.0": [
134
+ {
135
+ "zh": "建立 Pulse CLI 初始 npm 发布包和仓库发布元数据。",
136
+ "en": "Established the initial Pulse CLI npm package and repository release metadata."
137
+ }
138
+ ]
139
+ };
@@ -0,0 +1,6 @@
1
+ export interface SlashSuggestion {
2
+ name: string;
3
+ description: string;
4
+ }
5
+ export declare function skillSuggestions(names: readonly string[], commands: readonly SlashSuggestion[]): SlashSuggestion[];
6
+ export declare function searchSlashSuggestions(value: string, items: readonly SlashSuggestion[]): SlashSuggestion[];
@@ -0,0 +1,11 @@
1
+ export function skillSuggestions(names, commands) {
2
+ const reserved = new Set(commands.map(item => item.name.toLowerCase()));
3
+ return names.map(name => ({ name: reserved.has(`/${name}`.toLowerCase()) ? `/skill:${name}` : `/${name}`, description: '技能 · 提交时加载' }));
4
+ }
5
+ export function searchSlashSuggestions(value, items) {
6
+ if (!/^\/\S*$/.test(value))
7
+ return [];
8
+ const query = value.slice(1).toLowerCase();
9
+ return items.filter(item => item.name.slice(1).toLowerCase().includes(query))
10
+ .sort((a, b) => Number(b.name.slice(1).toLowerCase().startsWith(query)) - Number(a.name.slice(1).toLowerCase().startsWith(query)) || a.name.localeCompare(b.name));
11
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "A local-first AI task assistant for coding, research, and file workflows.",
5
+ "license": "PolyForm-Noncommercial-1.0.0",
5
6
  "keywords": [
6
7
  "ai",
7
8
  "agent",
@@ -27,7 +28,7 @@
27
28
  "scripts"
28
29
  ],
29
30
  "scripts": {
30
- "build": "tsc -p tsconfig.json",
31
+ "build": "pnpm --dir ../.. changelog:sync && tsc -p tsconfig.json",
31
32
  "postinstall": "node ./scripts/postinstall.mjs"
32
33
  },
33
34
  "publishConfig": {
@@ -35,7 +36,7 @@
35
36
  "registry": "https://registry.npmjs.org"
36
37
  },
37
38
  "dependencies": {
38
- "@hunterzhu/pulse-server": "0.4.0",
39
+ "@hunterzhu/pulse-server": "0.4.2",
39
40
  "chalk": "^5.4.1",
40
41
  "highlight.js": "^11.10.0",
41
42
  "ink": "^7.1.1",
@@ -1,58 +1,15 @@
1
+ import { createRequire } from 'node:module'
1
2
  import { ensurePulseUserConfig, defaultPulseConfigPath } from '../dist/config.js'
3
+ import { formatInstallWelcome, shouldUseColor } from '../dist/presentation.js'
2
4
 
3
- // Install-time welcome screen. Colors stay off for piped or captured install
4
- // logs and can be forced with FORCE_COLOR for debugging.
5
- const colorEnabled =
6
- process.env.NO_COLOR === undefined &&
7
- process.env.FORCE_COLOR !== '0' &&
8
- (process.stdout.isTTY === true || process.env.FORCE_COLOR !== undefined)
9
-
10
- const paint = (code) => (text) => (colorEnabled ? `\u001b[${code}m${text}\u001b[0m` : text)
11
- const cyan = paint('36')
12
- const bold = paint('1')
13
- const dim = paint('2')
14
-
15
- const banner = [
16
- '██████╗ ██╗ ██╗██╗ ███████╗███████╗',
17
- '██╔══██╗██║ ██║██║ ██╔════╝██╔════╝',
18
- '██████╔╝██║ ██║██║ ███████╗█████╗ ',
19
- '██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝ ',
20
- '██║ ╚██████╔╝███████╗███████║███████╗',
21
- '╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝',
22
- ].map((line) => cyan(line)).join('\n')
23
-
24
- function printWelcome({ configPath, error } = {}) {
25
- const lines = [
26
- '',
27
- banner,
28
- '',
29
- ` ${bold('Pulse')} - a neural-signal like AI assistant for coding, research and file tasks.`,
30
- '',
31
- ` ${dim('Get started:')}`,
32
- ` ${cyan('cd project_dir')}`,
33
- ` ${cyan('pulse')}`,
34
- '',
35
- ]
36
-
37
- if (configPath) {
38
- // Print the fully resolved absolute path on its own, uncolored line so it
39
- // can be copied straight out of the terminal without ANSI escape codes.
40
- lines.push(` ${dim('Config ready at')}`)
41
- lines.push(` ${configPath}`)
42
- } else {
43
- lines.push(` ${dim('Config not created:')} ${error}`)
44
- lines.push(` ${dim('Run "pulse setup" later to create it.')}`)
45
- }
46
-
47
- lines.push('')
48
- console.log(lines.join('\n'))
49
- }
5
+ const { version } = createRequire(import.meta.url)('../package.json')
6
+ const color = shouldUseColor(process.env, process.stdout.isTTY === true)
50
7
 
51
8
  try {
52
9
  const configPath = defaultPulseConfigPath()
53
10
  await ensurePulseUserConfig(configPath)
54
- printWelcome({ configPath })
11
+ process.stdout.write(formatInstallWelcome({ version, configPath, color }))
55
12
  } catch (error) {
56
13
  const message = error instanceof Error ? error.message : String(error)
57
- printWelcome({ error: message })
14
+ process.stdout.write(formatInstallWelcome({ version, error: message, color }))
58
15
  }