Skip to content

Shell 支线 · 读懂 pi-test.sh ​

pi-test.sh 是一个从本地源码启动 Pi CLI 的脚本。CLI 可以先理解为「在终端里使用的 Pi 程序」。你输入一条命令,它先做启动前的准备,再让 Node.js 执行 Pi 的入口文件。Node.js 是这里用来运行 JavaScript/TypeScript 代码的执行环境。

把它想成开店前的准备清单:找到店的位置、整理收到的要求、按需收起一些凭证,最后开门。Pi 的对话和工具功能由启动后的程序负责。

本支线只学习这一个文件。读完后,你应该能解释它每块代码的用途,并能说出「这条启动命令里的参数最后去了哪里」。涉及其他源码时只说明衔接关系,不展开新的学习单元。

阅读依据与范围 ​

项目本文依据
阅读日期2026-11-07,Asia/Taipei
本地文件pi/pi-test.sh,共 85 行
当前源码分支pi-1107-2026
完整 commit SHAe12dbfc9299049dc2e7a974612fec32001a7ba8a
Git 状态检查时 pi/ 工作区干净
本文验证Bash 语法、独立示例、用替身接收启动参数;未启动真实 Pi

这里记录的是本次实际检查到的版本。旧的运行指南依据另一个 commit;阅读本文时,以本表和本地脚本为准。

怎么读

按下面的源码顺序阅读即可。代码块标为「演算」时,用来帮助你在脑中执行,不必逐条输入终端。「深入示例」则提供可单独运行的对照实验:先猜结果,再执行,最后读原因。最后还有一个完整的参数处理小实验。

所有行号都指原文件的行号;代码块里没有额外加入行号,便于对照复制。空白行只用来分段,不执行任何操作。

先认识命令、脚本和参数 ​

Shell 是接受命令、调用程序的工具。脚本就是把多条命令按顺序写进文件。这里使用的 Shell 是 Bash。

终端是你输入和查看文字的窗口;窗口里运行的 Shell 可以是 zsh,也可以是 Bash。即使你的终端默认用 zsh,下面的命令也会明确请 Bash 来执行文件:

bash
bash ./pi-test.sh --no-env --help

把命令拆开:

部分意思
bash请 Bash 执行后面的脚本
./pi-test.sh当前目录中的这个文件;. 表示当前目录,/ 分隔路径
--no-env传给脚本的第一个参数
--help传给脚本的第二个参数

参数就是调用时附带的信息。参数的名字本身没有魔法,必须由代码识别才有意义。稍后会看到,--no-env 由这个脚本处理,--help 被转交给 Pi。

本文中的示例位置

真正的 Pi 启动命令需要在 /path/to/pi-study/pi 中执行,或使用脚本的完整路径。本文只解释这些命令;Pi 的依赖准备和实际启动仍按运行指南由你操作。

全文件的执行路线 ​

text
第 1~2 行:选择 Bash,设置错误处理规则
        ↓
第 4 行:计算脚本所在目录 SCRIPT_DIR
        ↓
第 6~15 行:遍历参数,取出 --no-env,保留其余参数
        ↓
第 17~81 行:如果启用了 --no-env,清除列出的环境变量
        ↓
第 83~84 行:把解析器的文件路径转换成 file URL
        ↓
第 85 行:让 Node 预加载解析器,再执行 Pi 的 cli.ts

下面逐块展开。第 3、5、16、82 行为空白行。

第 1 行:让谁读这份脚本 ​

bash
#!/usr/bin/env bash

这一行称为 shebang,可以读作「直接执行这个文件时,请用 Bash 解释它」。

  • #!:放在文件开头,标记解释器说明。
  • /usr/bin/env:这里用它在 PATH 中寻找程序。
  • bash:要寻找的程序名。

PATH 是一份目录清单。系统寻找 bash、node 这些没有写完整路径的命令时,会按这份清单寻找。

两种调用方式:

bash
bash ./pi-test.sh --help
./pi-test.sh --help

第一种已经明确指定 Bash,不依赖 shebang 来选择解释器。第二种直接执行文件,需要文件有可执行权限,并由 shebang 指明使用 Bash。

本学习工作区采用第一种。不要改用 sh ./pi-test.sh:后面的数组和 [[ ... ]] 使用了 Bash 语法,sh 不保证支持。

第 2 行:出现错误时怎么处理 ​

bash
set -euo pipefail

set 是 Bash 内置命令,这里用来开启几项执行规则。它相当于:

bash
set -e
set -u
set -o pipefail

先认识退出状态:命令结束后会返回一个数字,通常 0 表示成功,非 0 表示失败。下面的例子用 false 表示失败命令、true 表示成功命令。

-e:普通命令失败时停止 ​

演算:

bash
set -e
echo "开始"
false
echo "结束"

会打印「开始」,执行到 false 就退出,最后一行不会执行。这样前面准备失败时,就不会继续启动程序。

这里要保留一个准确的理解:不是任何非零状态都会触发退出。例如 if 后面的条件判断,本来就允许得到「不成立」的结果,不会因此退出。当前只需记住这个与本脚本有关的例外。详细规则见 Bash 官方手册的 set 说明。

-u:读取未定义变量时报错 ​

演算,假设从未定义过 PI_STUDY_NAME:

bash
set -u
echo "$PI_STUDY_NAME"

Bash 会报告 unbound variable,而不是悄悄用空字符串替代它。比如你把 SCRIPT_DIR 拼错了,就更容易发现。

「变量没有定义」和「变量定义了,但值是空字符串」是不同情况。后面会看到脚本如何处理空数组。

pipefail:管道前面的失败也要算进去 ​

管道用 | 把前一条命令的输出交给后一条命令。

演算:

bash
false | true

默认只看最后一条命令的状态,所以这条管道会被当作成功。开启 pipefail 后,只要管道中有命令失败,整条管道就返回失败;再配合 -e,这种普通执行场景下脚本会停止。

当前文件没有使用管道,这项规则在这份脚本的现有命令中没有实际发挥作用。

不必在当前终端直接试 set -e

这些代码是独立脚本的演算。如果想验证,把片段放入单独的 .sh 文件,再用 bash 文件路径 执行,避免把错误处理选项加到正在使用的终端会话里。

深入示例一:失败、空值和条件为假有什么区别? ​

先猜一猜: 开启 -e 后,if false 会不会让脚本退出?定义成 "" 的变量会不会触发 -u?

把下面整块内容保存为 /tmp/pi-errors-demo.sh,执行 bash /tmp/pi-errors-demo.sh。

bash
#!/usr/bin/env bash
# 外层不打开 -e,方便继续观察各个子脚本的结果。

bash -c 'set -e; echo "子脚本开始"; false; echo "不会执行"'
printf '子脚本的退出状态:%s\n' "$?"

bash -c 'set -e; if false; then echo "不会进入这里"; fi; echo "条件为假,仍能继续"'

bash -c 'set -u; PI_STUDY_EMPTY=""; printf "已定义的空值:<%s>\n" "$PI_STUDY_EMPTY"'
if bash -c 'set -u; unset PI_STUDY_MISSING; echo "$PI_STUDY_MISSING"'; then
  echo "没有发生预期错误"
else
  echo "已捕获未定义变量错误"
fi

bash -c 'false | true'
printf '默认管道状态:%s\n' "$?"
bash -c 'set -o pipefail; false | true'
printf '开启 pipefail 后的管道状态:%s\n' "$?"

这里增加两个观察工具:bash -c '一段命令' 会另开一个 Bash 执行引号里的命令;$? 读取上一条命令的退出状态。它必须紧跟要观察的命令,否则读到的就是别的命令的状态。printf 按格式输出,%s 放入后面的值,\n 表示换行。

正常输出为:

text
子脚本开始
子脚本的退出状态:1
条件为假,仍能继续
已定义的空值:<>
已捕获未定义变量错误
默认管道状态:0
开启 pipefail 后的管道状态:1

另外会出现一条包含 PI_STUDY_MISSING 和 unbound variable 的错误信息,具体前缀随 Bash 版本而异。正常输出与错误信息走不同的输出通道,合并显示时不要依赖两者的穿插顺序。

为什么是这个结果?

  • 第一个子脚本在 false 处失败,-e 让它停止;外层脚本仍能继续记录它的状态。
  • 第二个子脚本把 false 用作 if 的判断条件,只表示「不走 then 分支」。这正是原脚本比较 NO_ENV 时需要的行为。
  • 空字符串已经有定义,允许读取;从未定义或已被 unset 的变量,直接读取才会触发 -u。
  • false | true 最后一个命令成功,所以默认状态是 0;pipefail 让前面的失败也反映到整条管道的状态里。

回到原脚本: 第 2 行是在规定后续命令的失败处理方式,不是在逐项检查 API key 是否存在。后面的条件判断为假、清除一个不存在的普通变量,都不等于脚本执行失败。

第 4 行:找到脚本所在目录 ​

bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

整行结果是:把脚本所在目录的绝对路径,存进变量 SCRIPT_DIR。

例如在当前工作区,结果是:

text
/path/to/pi-study/pi

「绝对路径」从根目录 / 开始写出完整位置;「相对路径」如 ./pi-test.sh,则需要结合当前目录才能确定位置。

先懂四个小语法 ​

写法用途小例子
NAME=value给变量赋值,等号两边不加空格NAME=Pi
$NAME 或 ${NAME}读取变量的值echo "$NAME" 打印 Pi
$(命令)执行命令,把它的文字输出放到这里HERE="$(pwd)"
"文字或变量"保留空格等字符,作为一个整体"my project" 是一个参数

echo 表示把文字输出到终端;pwd 表示输出当前所在目录。命令替换 $(...) 会去掉输出末尾的换行,因此变量里通常不会多出一行。

从里向外拆开 ​

假设你已经在 pi/ 中执行:

bash
bash ./pi-test.sh
步骤片段这次调用中的结果
1${BASH_SOURCE[0]}当前脚本的路径,这里是 ./pi-test.sh
2dirname "./pi-test.sh"去掉文件名,得到 .
3cd "."进入这个目录
4&& pwd只有 cd 成功,才输出目录的绝对路径
5外层 $(...)收集这个绝对路径
6SCRIPT_DIR="..."把结果存起来,供第 84、85 行使用

BASH_SOURCE 是 Bash 提供的数组,[0] 取第一个元素。在这里,它告诉我们当前脚本的文件路径。数组可以先理解为「按顺序摆放的一组值」,后面会用到。

dirname 只提取目录部分,不保证输出绝对路径,所以还需要 cd 和 pwd。&& 可以读作「前面成功了,才做后面」。

这些双引号各自保护所在层级的路径;例如目录叫 my project,也应当作为一个路径传递。

为什么不直接用 pwd? ​

因为「你在哪个目录调用脚本」与「脚本放在哪个目录」可能不同。

演算:从 /tmp 调用同一个脚本:

bash
cd /tmp
bash /path/to/pi-study/pi/pi-test.sh --help

这时:

text
调用时的工作目录:/tmp
SCRIPT_DIR:/path/to/pi-study/pi

第 4 行中的 cd 位于 $(...) 的子 Shell 环境里,只用于计算路径,不会把整个脚本的工作目录切到 pi/。最后启动的 Node 仍继承调用时的工作目录。

这既让脚本能找到源码,也保留了用户选择的工作目录。这里没有专门解析符号链接,不要把它理解为一套查找链接最终目标的逻辑。

深入示例二:收集命令输出,会把里面的改动带出来吗? ​

先猜一猜: $(...) 里面修改变量、切换目录后,外面会不会一起改变?

保存为 /tmp/pi-substitution-demo.sh,执行 bash /tmp/pi-substitution-demo.sh:

bash
#!/usr/bin/env bash
set -eu

NAME=outer
RESULT="$(NAME=inner; printf '%s\n\n' "$NAME")"
printf '收集到的文字:<%s>\n' "$RESULT"
printf '外层的变量:<%s>\n' "$NAME"

START_DIR="$(pwd)"
CAPTURED_DIR="$(cd / && pwd)"
printf '收集到的目录:<%s>\n' "$CAPTURED_DIR"
if [[ "$(pwd)" == "$START_DIR" ]]; then
  echo "外层工作目录没有变化"
fi

预期输出,不受你从哪个目录调用它影响:

text
收集到的文字:<inner>
外层的变量:<outer>
收集到的目录:</>
外层工作目录没有变化

执行时发生了这些事:

  1. 子 Shell 把自己的 NAME 改为 inner,并打印它。
  2. $(...) 取走打印出的文字,并删除末尾的换行,因此 <inner> 中间没有额外空行。
  3. 外层原有的 NAME 仍是 outer。文字通过命令替换返回,变量赋值没有跟着返回。
  4. 另一个子 Shell 进入 / 并输出路径;外层只收到 / 这个文字结果,没有跟着切换目录。

回到原脚本: 第 4 行需要的是计算结果 SCRIPT_DIR,所以把 cd ... && pwd 放进 $(...)。第 84 行用同一种机制收集 Node 打印的 URL。它们都是「执行命令,取回文字」,不是把命令内容存成文字以后再执行。

第 6~8 行:准备开关和参数收纳盒 ​

bash
# Check for --no-env flag
NO_ENV=false
ARGS=()

逐行解释:

行作用
6# 开头的是注释:提示下面要检查 --no-env
7把 NO_ENV 设为文字 false,表示默认不开启清理
8创建空数组 ARGS,用来装「需要交给 Pi 的参数」

Shell 在这里把 false、后面的 true 当作字符串保存。第 17 行会拿它们进行比较。这与执行一条叫 false 的命令是两回事。

为什么不能直接把所有参数交给 Pi?因为 --no-env 是这个启动脚本自己的开关,需要先取出来。数组相当于「保留下来的参数收纳盒」。

第 9~15 行:逐个查看参数 ​

bash
for arg in "$@"; do
  if [[ "$arg" == "--no-env" ]]; then
    NO_ENV=true
  else
    ARGS+=("$arg")
  fi
done

可以把这一块翻译成:

把每个参数拿出来看一遍。遇到 --no-env,打开开关;遇到其他内容,就原样放进收纳盒。

每一行分别做什么 ​

行代码通俗解释
9for arg in "$@"; do逐个取出传入参数,本轮取到的值叫 arg
10if [[ "$arg" == "--no-env" ]]; then判断这个参数是否刚好等于 --no-env
11NO_ENV=true如果相等,把开关打开
12else否则走另一条分支
13ARGS+=("$arg")把当前参数追加为数组的一个元素
14fi结束这次 if 判断
15done结束循环体,还有参数就继续下一轮

[[ ... ]] 是 Bash 的条件判断写法;这里的 == 用来比较文字。; 把同一行里的语句隔开,所以 then、do 可以紧接着写在同一行。

"$@" 表示「所有传入参数,每个参数仍然是独立的一项」。没有参数时,循环一次也不执行。

跟着一条命令逐轮走 ​

演算:

bash
bash ./pi-test.sh --no-env --help
时刻argNO_ENVARGS 里的内容
初始尚未取参数false空
第 1 轮--no-env改成 true空
第 2 轮--help保持 true一个元素:--help
循环结束—true一个元素:--help

结果是 --no-env 被脚本消费,--help 被保留。原始的 "$@" 没有被修改,脚本只是另建了 ARGS。

为什么引号和数组很重要? ​

再看这条演算命令:

bash
bash ./pi-test.sh --no-env "你好 Pi"

"你好 Pi" 包含空格,但它是一个参数。双引号用于告诉 Shell 如何分组,不会作为文字的一部分传进去。

所以 ARGS 最后只有一个元素,内容是 你好 Pi。脚本通过 "$@" 和 ARGS+=("$arg") 保留了这个边界,后续也会保持它。

如果没有 --no-env,所有参数都会进入 ARGS,开关仍是 false。如果出现多个 --no-env,每次只是把同一个开关设为 true,这些参数都不会进入 ARGS。

深入示例三:去掉引号,参数真的会变吗? ​

先猜一猜: '$WORD'、"$WORD" 输出相同吗?把包含空格的变量写成 $TEXT,循环会执行几次?

保存为 /tmp/pi-quotes-demo.sh,执行 bash /tmp/pi-quotes-demo.sh:

bash
#!/usr/bin/env bash
set -eu

WORD=Pi
printf '双引号:<%s>\n' "$WORD"
printf '单引号:<%s>\n' '$WORD'

TEXT="你好 Pi"
for arg in $TEXT; do
  printf '未加引号:<%s>\n' "$arg"
done
for arg in "$TEXT"; do
  printf '加双引号:<%s>\n' "$arg"
done

预期输出:

text
双引号:<Pi>
单引号:<$WORD>
未加引号:<你好>
未加引号:<Pi>
加双引号:<你好 Pi>

第一组观察的是是否读取变量:双引号中的 $WORD 会替换为 Pi,单引号中的 $WORD 原样保留。

第二组观察的是替换后的文字怎样分组:在本例的默认分隔规则下,未加引号的 $TEXT 会按空格拆成两项;"$TEXT" 则保持一项。因此,第一轮循环执行两次,第二轮只执行一次。

写法本例发生什么与原脚本的关系
'$WORD'保留字面文字 $WORD第 84 行的单引号让 Shell 原样传递 JavaScript 表达式
"$WORD"读取变量,并保留参数边界"$SCRIPT_DIR/..." 既要读取目录,也要保护路径里的空格
$TEXT读取后继续拆词若循环里去掉必要的引号,原来一个参数可能变成多个

回到原脚本: "$@"、"$arg"、数组元素周围的引号都在帮助保留参数边界。引号不是统一表示「纯文字」,要分清单引号与双引号。

第 17~81 行:按需清除环境变量 ​

第 17~18 行:先判断开关 ​

bash
if [[ "$NO_ENV" == "true" ]]; then
  # Unset API keys (see packages/ai/src/env-api-keys.ts)

第 17 行:只有 NO_ENV 的值为 true,才执行这一块。否则直接跳到第 81 行之后。

第 18 行:注释指出相关凭证读取代码在 packages/ai/src/env-api-keys.ts。本篇只用它核对这些变量的背景,不展开它的实现。

先懂环境变量与 unset ​

变量可以看成贴着名字的小盒子。环境变量则是会传给子进程的那一类变量,例如服务的 API key。子进程就是当前程序启动的另一个程序,这里 Bash 会启动 Node。

演算,使用虚构的示例变量:

bash
export PI_STUDY_DEMO_KEY="demo-key-not-real"
unset PI_STUDY_DEMO_KEY

第一行通过 export 设置一个可被子进程继承的环境变量。第二行 unset 删除当前 Shell 里的这个变量。

unset NAME 后面写的是变量名,不是 "$NAME":这里要删除盒子本身,不是拿出里面的值。删除一个本来就不存在的普通变量也可以,不会因为 set -u 就报错。

第 19~79 行:同一操作,作用于不同名字 ​

这 61 行都是 unset 变量名,语法完全相同,因此按用途分块读。下面完整保留原代码的顺序。

第 19~36 行:一组 API key 与 token。 key、token 可以先理解为服务用来识别身份的凭证。

bash
unset ANTHROPIC_API_KEY
unset ANTHROPIC_OAUTH_TOKEN
unset OPENAI_API_KEY
unset GEMINI_API_KEY
unset GROQ_API_KEY
unset CEREBRAS_API_KEY
unset XAI_API_KEY
unset OPENROUTER_API_KEY
unset ZAI_API_KEY
unset MISTRAL_API_KEY
unset MINIMAX_API_KEY
unset MINIMAX_CN_API_KEY
unset AI_GATEWAY_API_KEY
unset OPENCODE_API_KEY
unset COPILOT_GITHUB_TOKEN
unset GH_TOKEN
unset GITHUB_TOKEN
unset HF_TOKEN

例如,调用前若有 OPENAI_API_KEY=demo-key-not-real,执行第 21 行后,脚本启动的 Node 就不会从继承环境中收到这个变量。其余行对各自名字做相同的事,无需逐个背下来。

第 37~40 行:Google Cloud 的凭证路径和项目、区域设置。

bash
unset GOOGLE_APPLICATION_CREDENTIALS
unset GOOGLE_CLOUD_PROJECT
unset GCLOUD_PROJECT
unset GOOGLE_CLOUD_LOCATION

例子:GOOGLE_APPLICATION_CREDENTIALS 可以保存凭证文件路径。清除变量只是让程序不再通过这个环境变量得到路径,不会删除路径对应的文件。项目和区域设置也一并清除。

第 41~50 行:AWS 的配置、密钥和凭证来源信息。

bash
unset AWS_PROFILE
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN
unset AWS_REGION
unset AWS_DEFAULT_REGION
unset AWS_BEARER_TOKEN_BEDROCK
unset AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
unset AWS_CONTAINER_CREDENTIALS_FULL_URI
unset AWS_WEB_IDENTITY_TOKEN_FILE

例子:AWS_PROFILE=study 可以指向一套名为 study 的配置。清除它后,启动进程不会通过这个变量得知要选用 study。这一块还清除密钥、区域、容器凭证地址、身份令牌文件路径等环境信息。

第 51~53 行:Azure OpenAI 的密钥与服务地址设置。

bash
unset AZURE_OPENAI_API_KEY
unset AZURE_OPENAI_BASE_URL
unset AZURE_OPENAI_RESOURCE_NAME

例子:如果 AZURE_OPENAI_RESOURCE_NAME=study-resource,清除后 Node 就不再从该变量读到这个资源名。可见这一大块清除的内容不限于密钥,也包括服务位置等配置。

第 54~59 行:Anthropic 的另一组认证相关设置。

bash
unset ANTHROPIC_AUTH_TOKEN
unset ANTHROPIC_FEDERATION_RULE_ID
unset ANTHROPIC_IDENTITY_TOKEN_FILE
unset ANTHROPIC_ORGANIZATION_ID
unset ANTHROPIC_SERVICE_ACCOUNT_ID
unset ANTHROPIC_WORKSPACE_ID

例子:ANTHROPIC_IDENTITY_TOKEN_FILE 若存着令牌文件路径,清除后就无法再通过这个环境变量获取它。组织、服务账号和工作区等变量同样被清除。本篇不要求掌握这些认证方式。

第 60~79 行:其余列出的服务凭证。

bash
unset ANT_LING_API_KEY
unset BASETEN_API_KEY
unset CLOUDFLARE_API_KEY
unset DEEPSEEK_API_KEY
unset FIREWORKS_API_KEY
unset GOOGLE_CLOUD_API_KEY
unset KIMI_API_KEY
unset META_API_KEY
unset MOONSHOT_API_KEY
unset NVIDIA_API_KEY
unset QWEN_TOKEN_PLAN_API_KEY
unset QWEN_TOKEN_PLAN_CN_API_KEY
unset RADIUS_API_KEY
unset TOGETHER_API_KEY
unset TYPESAFE_API_KEY
unset XIAOMI_API_KEY
unset XIAOMI_TOKEN_PLAN_CN_API_KEY
unset XIAOMI_TOKEN_PLAN_AMS_API_KEY
unset XIAOMI_TOKEN_PLAN_SGP_API_KEY
unset ZAI_CODING_CN_API_KEY

例子:第 63 行删除 DEEPSEEK_API_KEY;第 72 行删除 RADIUS_API_KEY。每行只处理自己后面写出的那个名字,没有「自动清除所有以 API_KEY 结尾的变量」这样的规则。

第 80~81 行:显示提示并结束条件块 ​

bash
  echo "Running without API keys..."
fi

第 80 行输出提示:

text
Running without API keys...

第 81 行 fi 结束整个条件块。之后仍然继续启动 Pi;这条消息不是程序结束标志。

--no-env 到底影响到哪里? ​

正常使用 bash ./pi-test.sh ... 时,关系是:

text
你正在使用的终端 Shell:保留原来的环境
  └─ 执行脚本的 Bash:按需清除列出的变量
       └─ 启动的 Node:继承清除后的环境

父进程不会因为子进程里的 unset 而丢失变量。因此它不是永久清除你电脑上的 key,也不会改动 Shell 配置文件。

开关的准确含义是:启动前,清除脚本明确列出的环境变量。它没有清空全部环境,没有删除 auth.json 里的登录信息,也没有禁用网络。凭证还可能来自其他配置;因此不能把提示理解为「Pi 一定无法登录或调用模型」。文件凭证的衔接可在 packages/coding-agent/src/core/auth-storage.ts 看到。

本篇始终使用 bash 文件名。不要用 source pi-test.sh 来试验:source 会在当前 Shell 里执行文件,变量清理与 set 选项的影响范围就变了。

深入示例四:子进程能收到什么,又能改掉什么? ​

先猜一猜: 只赋值、不 export 的变量,子 Bash 能读到吗?子 Bash 删除一个变量后,父 Bash 的同名变量还在吗?

保存为 /tmp/pi-env-demo.sh,执行 bash /tmp/pi-env-demo.sh:

bash
#!/usr/bin/env bash
set -eu

unset PI_STUDY_LOCAL PI_STUDY_KEY
PI_STUDY_LOCAL=local-only
export PI_STUDY_KEY=demo-key-not-real

bash -c '
  printf "子进程的普通变量:<%s>\n" "${PI_STUDY_LOCAL-未设置}"
  printf "子进程收到的 key:<%s>\n" "$PI_STUDY_KEY"
  unset PI_STUDY_KEY
  printf "子进程清理后:<%s>\n" "${PI_STUDY_KEY-未设置}"
'

printf '父进程的 key:<%s>\n' "$PI_STUDY_KEY"

这里的 ${变量-未设置} 表示:变量未定义时,临时显示「未设置」;它没有给变量赋值。用这个写法,是为了能观察未定义状态,不直接读取不存在的变量。

预期输出:

text
子进程的普通变量:<未设置>
子进程收到的 key:<demo-key-not-real>
子进程清理后:<未设置>
父进程的 key:<demo-key-not-real>

开头先清除两个示例变量,是为了让实验从确定的状态开始。随后,普通赋值只供当前 Bash 使用;export 则把变量加入将传给子进程的环境。

可以把这次继承理解成「启动时收到一份环境」:子进程能修改自己的那份,但不会反向修改父进程那份。这次 bash -c 里的单引号也很关键:它让 $PI_STUDY_KEY 等写法交给子 Bash再读取。

回到原脚本: 终端把已导出的 key 传给执行脚本的 Bash;脚本按 --no-env 清理后,再把剩余环境传给 Node。没有使用 export 的 NO_ENV、SCRIPT_DIR、RESOLVER_URL 不需要作为环境传下去,因为 Bash 会读取它们,直接拼成命令参数。

第 83~84 行:把解析器路径变成文件 URL ​

bash
# --import takes a module specifier, so pass the resolver as a file URL (raw paths break on #, ?, %).
RESOLVER_URL="$(node -p 'require("node:url").pathToFileURL(process.argv[1]).href' "$SCRIPT_DIR/packages/coding-agent/src/experimental/source-resolver.ts")"

第 83 行说明原因:接下来要给 --import 传一个模块地址;路径包含 #、?、% 时,直接拿原始路径充当地址会出问题,因此转换成正确的 file: URL。

URL 是一种地址写法,file: 表示本地文件。它不表示联网下载。

第 84 行分成两层读 ​

外层还是熟悉的「执行命令,保存输出」:

bash
RESOLVER_URL="$(某条命令)"

里面的命令是:

bash
node -p 'require("node:url").pathToFileURL(process.argv[1]).href' "$SCRIPT_DIR/packages/coding-agent/src/experimental/source-resolver.ts"
片段作用
node调用 Node.js
-p执行后面的 JavaScript 表达式,并打印计算结果
外侧单引号 '...'把这段 JavaScript 作为一个参数原样交给 Node
require("node:url")取得 Node 内置的 URL 工具
process.argv[1]这里取到表达式后面附带的文件路径
pathToFileURL(...)把文件路径转换成文件 URL 对象
.href取出对象里的完整地址文字
最后的双引号路径要转换的文件位置,拼接在 SCRIPT_DIR 后面

本篇不展开 JavaScript。你先把中间那段理解成「Node 提供的文件路径转地址工具」即可。

注意 process.argv[1] 是这次 node -p 调用的参数位置;它不是 Bash 的第一个参数,也不是所有 Node 调用都把用户参数放在这里。

当前工作区会得到:

text
file:///path/to/pi-study/pi/packages/coding-agent/src/experimental/source-resolver.ts

为什么不能只在路径前面加 file://? ​

演算:假设某个目录名含有空格、井号、问号和百分号:

text
原文件路径:/tmp/pi #1?%/source-resolver.ts
转换后地址:file:///tmp/pi%20%231%3F%25/source-resolver.ts

%20、%23、%3F、%25 分别表示这些特殊字符,避免被当成 URL 的其他组成部分。加双引号只能保护 Shell 传参,不能替代 URL 编码。这也是使用 pathToFileURL 的原因。Node 官方说明

这一行只计算地址,没有执行解析器本身。它会启动一次 Node 来做转换;下一行还会另外启动一次 Node 来运行 Pi。

第 85 行:交给 Node 启动 Pi ​

bash
node --import "$RESOLVER_URL" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"}

先把最后一段暂时读作「剩下的参数」,整句就是:

请 Node 先加载这个解析器,再运行 cli.ts,并把保留下来的参数交给它。

前半句:启动顺序与文件职责 ​

部分作用
node启动 Node
--import "$RESOLVER_URL"在入口运行前预加载指定模块
"$SCRIPT_DIR/packages/coding-agent/src/cli.ts"执行 Pi 的源码入口
最后那段数组展开传入第 9~15 行保留下来的参数

--import 是 Node 的参数,而不是 Pi 的参数。「先预加载模块,再执行入口」是这个选项的行为。Node 官方说明

这里预加载的 source-resolver.ts 负责按根目录 tsconfig.json 的路径配置,把工作区内部的 @earendil-works/... 引用解析到源码位置。你可以先把它理解成「让源码中的包名指向正确文件的指路器」。该职责已核对解析器源码,具体实现留到将来需要时再学。

cli.ts 是入口,内容很短:

ts
#!/usr/bin/env node
import { setupCli } from "./cli/setup.ts";
import { main } from "./main.ts";

setupCli();
main(process.argv.slice(2));

这里只观察交接点:它准备 CLI,再把入口后面的用户参数交给 main。这就回答了「Shell 脚本忙完以后,Pi 从哪里开始运行」。

后半句:为什么数组展开这么复杂? ​

bash
${ARGS[@]+"${ARGS[@]}"}

先学里面那层:

bash
"${ARGS[@]}"

它表示「把数组的每个元素分别作为一个参数展开」。每个元素原有的空格、空字符串和 * 等字符都保留,不会再次拆词或展开成文件名。

外层用到了 Bash 的一种规则:

bash
${变量+替代内容}

意思是:「变量已设置时,用这里的替代内容;没有设置时,什么也不放进去。」这里的 + 是参数展开语法,不是加法,也不是第 13 行的数组追加。这种没有冒号的 + 写法区分的是是否设置。Bash 官方参数展开说明

放到这行的数组场景中,你可以按结果记:

ARGS 的内容最后传给 CLI 的参数
ARGS=()0 个
ARGS=("--help")1 个:--help
ARGS=("你好 Pi")1 个:你好 Pi
ARGS=("")1 个:空字符串
ARGS=("--help" "你好 Pi")2 个,顺序不变

它处理了「没有任何剩余参数」的情况,尤其兼顾旧 Bash 在 set -u 下直接展开空数组可能报错的行为。当前 macOS 的 Bash 3.2.57 已验证该写法在空数组时传入 0 个参数。

这里没有额外把整个表达式再包上一层双引号;内部的 "${ARGS[@]}" 已按元素保护参数。照原样理解即可。

深入示例五:逐项展开、合成一项和空数组 ​

先猜一猜: 数组中有 "你好 Pi"、""、"*.ts" 三项,"${ARGS[@]}" 会传三个参数吗?"${ARGS[*]}" 呢?

保存为 /tmp/pi-array-demo.sh,执行 bash /tmp/pi-array-demo.sh:

bash
#!/usr/bin/env bash
set -eu

show_args() {
  printf '参数个数=%s\n' "$#"
  for item in "$@"; do
    printf '参数:<%s>\n' "$item"
  done
}

ARGS=("你好 Pi" "" "*.ts")
echo "逐项展开:"
show_args "${ARGS[@]}"
echo "合成一项:"
show_args "${ARGS[*]}"

echo "空数组:"
ARGS=()
show_args ${ARGS[@]+"${ARGS[@]}"}
echo "一个空字符串:"
ARGS=("")
show_args ${ARGS[@]+"${ARGS[@]}"}

unset PI_STUDY_VALUE
printf '未设置:<%s>\n' "${PI_STUDY_VALUE+已设置}"
PI_STUDY_VALUE=""
printf '设置为空:<%s>\n' "${PI_STUDY_VALUE+已设置}"

show_args() { ... } 定义一个函数,即给一组命令起个名字。调用时,它内部的 $# 是收到的参数个数,"$@" 是收到的各项参数。这里用它代替 Node,以便看清传入内容。

预期输出,使用 Bash 默认的分隔规则:

text
逐项展开:
参数个数=3
参数:<你好 Pi>
参数:<>
参数:<*.ts>
合成一项:
参数个数=1
参数:<你好 Pi  *.ts>
空数组:
参数个数=0
一个空字符串:
参数个数=1
参数:<>
未设置:<>
设置为空:<已设置>

逐项看结果:

  • "${ARGS[@]}" 保留三项,包括中间的空字符串;*.ts 也作为文字保留。
  • "${ARGS[*]}" 把元素连接成一个参数;默认用空格连接。输出中 Pi 与 *.ts 之间有两个空格,因为中间还有一个空元素。
  • ARGS=() 连一项也没有;ARGS=("") 则有一项,只是它的文字长度为零。因此不能把两种情况都理解成「传了个空的东西」。
  • 最后两行用普通变量单独拆解外层的 +:没设置时什么也不替代,设置为空字符串时也会产生「已设置」。它检查的是是否设置,不是字符串有没有内容。

回到原脚本: 第 85 行要保留原有参数的个数、顺序与内容,所以用逐项展开,并对没有剩余参数的情况加上处理。若改成 "${ARGS[*]}",--help 和其他参数可能被合成一整项,CLI 就收不到原来的参数列表了。

这个实验中的普通变量用于说明 + 的基本规则;数组仍按前面的数组展开结果理解,不要用「空字符串有没有定义」代替数组元素个数的判断。

深入示例六:先加载模块,再运行入口,怎么观察? ​

第 85 行有两条文件路径。可以用两个极小的文件,看清各自的执行时机;不需要启动 Pi 或理解解析器内部实现。

先创建 /tmp/pi-preload-demo.mjs:

js
console.log("1. 先执行预加载文件");

再创建 /tmp/pi-entry-demo.mjs:

js
console.log("2. 再执行入口文件");
console.log("入口收到的参数:" + JSON.stringify(process.argv.slice(2)));

.mjs 是明确按 JavaScript 模块运行的文件。console.log 输出文字;最后一行把入口收到的用户参数打印成一个列表,其中 "" 表示空字符串。这里用 .mjs 是为了只观察加载与传参,不引入 TypeScript 的运行条件。

在终端执行:

bash
node --import file:///tmp/pi-preload-demo.mjs /tmp/pi-entry-demo.mjs "你好 Pi" ""

预期输出:

text
1. 先执行预加载文件
2. 再执行入口文件
入口收到的参数:["你好 Pi",""]

然后只去掉 --import 和它后面的文件 URL,再执行:

bash
node /tmp/pi-entry-demo.mjs "你好 Pi" ""

这时只会看到后两行:入口仍会执行,但预加载文件没有被执行。

回到原脚本: --import 后面的 URL 是 Node 用来选择预加载模块的;cli.ts 后面的 ARGS 才是交给入口的参数。预加载模块不是一条需要 Pi 自己解析的用户参数。原脚本先让解析器安装路径解析规则,之后 CLI 及其依赖的加载才能使用这些规则。

把整条命令从头演算到尾 ​

场景一:没有附带参数 ​

bash
bash ./pi-test.sh
  1. 算出 SCRIPT_DIR。
  2. 循环没有参数可读;NO_ENV=false,ARGS 为空。
  3. 跳过所有 unset。
  4. 生成 RESOLVER_URL。
  5. Node 预加载解析器,再执行 cli.ts,没有附带 CLI 参数。

场景二:清除列出的环境变量,再显示帮助 ​

bash
bash ./pi-test.sh --no-env --help
  1. --no-env 把开关设为 true。
  2. --help 被放入 ARGS。
  3. 执行列出的 unset,显示 Running without API keys...。
  4. 生成解析器地址。
  5. 最后交接的命令效果如下,为便于阅读换成多行:
bash
node \
  --import "file:///path/to/pi-study/pi/packages/coding-agent/src/experimental/source-resolver.ts" \
  "/path/to/pi-study/pi/packages/coding-agent/src/cli.ts" \
  --help

行尾的 \ 表示命令还没结束,下一行继续。Pi 收到的是 --help,没有收到 --no-env。

场景三:清理后没有剩余参数 ​

bash
bash ./pi-test.sh --no-env

脚本清理变量并显示提示,但 ARGS 仍是空数组。因此它仍会启动源码入口,只是没有附带 CLI 参数。最后一行那段空数组处理就保证了这种调用可以顺利传参。

可以亲手做的最小实验 ​

这个实验只学习参数处理,不启动 Pi,不读取或清除真实凭证。它是教学片段,不是 c-pi/ 中的 Pi 复现代码。

用编辑器新建文件 /tmp/pi-args-demo.sh,将下面整块内容保存进去:

bash
#!/usr/bin/env bash
set -euo pipefail

NO_ENV=false
ARGS=()
for arg in "$@"; do
  if [[ "$arg" == "--no-env" ]]; then
    NO_ENV=true
  else
    ARGS+=("$arg")
  fi
done

echo "NO_ENV=$NO_ENV"
show_args() {
  echo "剩余参数个数:$#"
  for item in "$@"; do
    printf '参数:<%s>\n' "$item"
  done
}
show_args ${ARGS[@]+"${ARGS[@]}"}

实验后半段用 show_args 替代真实的 Node 启动。show_args() { ...; } 定义一组可重复调用的命令;函数内部的 "$@" 是函数收到的参数,$# 是其参数个数。printf 按格式打印文字,%s 放入参数内容,\n 换行。尖括号只是帮助看清空格和空字符串的边界。

在系统终端执行:

bash
bash /tmp/pi-args-demo.sh --no-env --help "你好 Pi"

预期输出:

text
NO_ENV=true
剩余参数个数:2
参数:<--help>
参数:<你好 Pi>

再执行:

bash
bash /tmp/pi-args-demo.sh

预期输出:

text
NO_ENV=false
剩余参数个数:0

最后试试:

bash
bash /tmp/pi-args-demo.sh --no-env "" "*.ts"

预期输出:

text
NO_ENV=true
剩余参数个数:2
参数:<>
参数:<*.ts>

这说明「空数组」与「有一个空字符串元素的数组」不同,也说明 *.ts 可以作为原样文字传递。这里引号必须保留;否则当前终端可能在脚本收到参数前就把通配符展开了。

本文实际验证了什么 ​

2026-11-07,在当前工作区完成:

检查实际结果
原脚本 bash -n pi/pi-test.sh通过;仅解析语法,不执行脚本
Bash 版本3.2.57(1)-release
-e、-u、pipefail 的独立示例符合上述失败和退出行为
空数组与一个空字符串的展开分别传入 0 个和 1 个参数
无参数、--help、--no-env --help 的原脚本传参使用 Node 替身接收,结果符合演算
带空格、空字符串、字面量 *.ts 的传参参数边界与顺序保留
用虚构 key 检查 --no-env替身收到的环境里,该变量已移除;调用方仍保留示例值
从其他目录调用脚本源码路径仍正确,启动进程保留调用目录
特殊字符路径转 URL得到 file:///tmp/pi%20%231%3F%25/source-resolver.ts
上面的教学片段三组命令均得到预期输出
本轮新增的深入示例错误规则、命令替换、引号、环境继承、数组、Node 预加载六组示例的输出已核对;Node 为 v24.15.0
文档站点npm run docs:build 通过

Node 替身是临时放置的一个接收程序:第 84 行仍委托真实 Node 转换 URL,第 85 行只记录参数、工作目录和虚构变量,不执行解析器或 cli.ts。所以这些结果证明了脚本如何准备与交接,不能据此认定真实 Pi 已启动或模型已连接。验证使用的临时文件已清理。

用自己的话回答这五个问题 ​

  1. 从 /tmp 调用脚本时,SCRIPT_DIR 和启动进程的工作目录分别是什么?
  2. 为什么 --no-env 不会出现在传给 Pi 的 ARGS 里?
  3. unset OPENAI_API_KEY 会不会删除文件、清除父终端变量或退出登录?
  4. 第 84 行的 Node 与第 85 行的 Node,各自做什么?
  5. "你好 Pi" 为什么从头到尾都能保持为一个参数?

可以对照的答案:

  1. SCRIPT_DIR 指向脚本所在的 pi/,启动进程的工作目录仍为 /tmp。
  2. 循环遇到它时只设置开关,走的是没有追加数组的那个分支。
  3. 都不会;正常用 bash 文件名 执行时,它只删除脚本进程中的对应变量,并影响其后代进程。
  4. 前者计算文件 URL;后者预加载解析器并启动 Pi 入口。
  5. 调用时的引号划定边界,脚本的引号与数组继续保留这个边界。

下一步只需对照原文件,尝试自己讲一遍执行路线。暂时不扩展成完整 Shell 教程,也不进入解析器或 CLI 内部实现。