Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@

- `--source` 由宿主 Agent 根据可信运行环境填写真实、稳定的平台标识;不得固定为 `codex` 或默认代填,来源未知时省略。示例中的平台名仅为示例,生成提交由 CLI 透传为服务端 `platform`;安装/更新的 `report_telemetry` 用新增 `host_platform` 承载宿主标识,保留已有 `source` 的安装入口语义和 `platform` 的操作系统语义。已有上报字段被消费时,不得改写其含义,应新增独立字段。

- 来源采集应尽力而为:显式 `--source` 优先,其次宿主提供的 `PIPPIT_CLI_SOURCE`,最后使用已核实的运行标记;显式空值禁用兜底。仅上报稳定宿主名,不上传会话 ID,不用安装痕迹、API Key 或 prompt 猜来源,冲突时省略。新增运行标记须有实际环境或官方实现依据。
- 来源采集应尽力而为:显式 `--source` 优先,其次宿主提供的 `PIPPIT_CLI_SOURCE`,最后使用已核实的运行标记;显式空值禁用兜底。仅上报稳定宿主名,不上传会话 ID,不用安装痕迹、API Key 或 prompt 猜来源,冲突时省略。新增运行标记须有实际环境或官方实现依据。npm postinstall、install 向导和原生 update 都必须覆盖同一来源优先级;验证须检查各入口实际发出的请求体,不能只验证通用解析函数或原生命令。
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,9 @@ npx @pippit-dev/cli install --source HOST
pippit-tool-cli update --source HOST
```

直接使用 `npm install -g @pippit-dev/cli` 时,可由宿主在该次命令的环境中设置 `PIPPIT_CLI_SOURCE`。install/update 的显式 `--source` 优先于此环境变量;首尾空白去除,显式空值清空来源。该值不持久化;生成命令运行环境中若也存在此变量,同样会读取它作为宿主来源,具体兜底规则见下文。
直接使用 `npm install -g @pippit-dev/cli` 时,可由宿主在该次命令的环境中设置 `PIPPIT_CLI_SOURCE`。安装和更新均按显式 `--source`(命令支持时)、`PIPPIT_CLI_SOURCE`、已核实宿主运行标记的顺序识别来源;首尾空白去除,显式空值清空来源。该值不持久化;生成命令运行环境中若也存在此变量,同样会读取它作为宿主来源,具体兜底规则见下文。

`report_telemetry` 新增可选请求字段 `host_platform` 上报宿主标识;原有 `source` 保留 `npm_install`、`npx_install`、`cli_update`,`event` 区分 install/update,`platform` 仍表示操作系统。当前仅完成 CLI 字段上报,服务端协议和指标接收仍需适配。帮助不安装也不上报,`PIPPIT_CLI_DISABLE_TELEMETRY=1` 仍可关闭上报。内部 `install-cli.js` 仅安装二进制,沿用不安装 Skill、不上报的原有行为。
`report_telemetry` 新增可选请求字段 `host_platform` 上报宿主标识;原有 `source` 保留 `npm_install`、`npx_install`、`cli_update`,`event` 区分 install/update,`platform` 仍表示操作系统。服务端通过独立的 `host_platform` 指标标签统计宿主来源。帮助不安装也不上报,`PIPPIT_CLI_DISABLE_TELEMETRY=1` 仍可关闭上报。内部 `install-cli.js` 仅安装二进制,沿用不安装 Skill、不上报的原有行为。

### 宿主来源统计

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pippit-dev/cli",
"version": "1.0.37",
"version": "1.0.38",
"description": "Pippit CLI",
"bin": {
"pippit-tool-cli": "scripts/run.js"
Expand Down
27 changes: 25 additions & 2 deletions scripts/install-cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function load(file, { modules = {}, process: overrides = {}, dirname, main = fal
const req = (name) => Object.prototype.hasOwnProperty.call(modules, name) ? modules[name] : require(name);
req.main = main ? module : null;
vm.runInNewContext(fs.readFileSync(file, "utf8"), {
require: req, module, process: proc, Buffer,
require: req, module, process: proc, Buffer, URL,
__dirname: dirname || path.dirname(file),
console: { log: (s) => output.push(s), warn: (s) => errors.push(s), error: (s) => errors.push(s) },
}, { filename: file });
Expand All @@ -39,6 +39,16 @@ function checkInstaller() {
const packageDir = path.join(root, "package");
fs.mkdirSync(packageDir);
const effects = [];
const payloads = [];
const environment = { CODEX_THREAD_ID: 'private-id' };
const telemetry = load(path.join(repository, "scripts/telemetry.js"), {
process: { env: environment },
modules: {
"../package.json": { version: "9.9.9" },
http: { request: () => ({ on() {}, end: body => payloads.push(JSON.parse(body)) }) },
https: { request: () => ({ on() {}, end: body => payloads.push(JSON.parse(body)) }) },
},
});
const archive = Buffer.from("verified archive fixture");
const mockPlatform = {
isWindows: process.platform === "win32",
Expand All @@ -58,14 +68,18 @@ function checkInstaller() {
};
const installer = load(path.join(repository, "scripts/install.js"), {
dirname: path.join(packageDir, "scripts"),
process: { env: environment },
modules: {
"../package.json": { version: "9.9.9" },
"./platform": mockPlatform,
"./skills": {
installSkillsFromRoot: () => effects.push("install-skills"),
cleanupLegacyGlobalSkills: () => effects.push("cleanup-skills"),
},
"./telemetry": { reportBundledSkillTelemetry: () => effects.push("telemetry") },
"./telemetry": { reportBundledSkillTelemetry: (...args) => {
effects.push("telemetry");
telemetry.api.reportBundledSkillTelemetry(...args);
} },
},
});
const hash = crypto.createHash("sha256").update(archive).digest("hex");
Expand All @@ -85,9 +99,18 @@ function checkInstaller() {
delete installer.proc.env.PIPPIT_CLI_SKIP_SKILLS;
installer.api.install();
assert.deepStrictEqual(effects.splice(0), ["install-skills", "telemetry"]);
assert.strictEqual(payloads.length, 2);
for (const payload of payloads.splice(0)) {
assert.strictEqual(payload.host_platform, "codex");
assert.strictEqual(payload.source, "npm_install");
assert.strictEqual(payload.event, "install");
assert.strictEqual(payload.platform, process.platform);
assert(!JSON.stringify(payload).includes("private-id"));
}
installer.proc.env.PIPPIT_CLI_SKIP_SKILLS = "1";
installer.api.install();
assert.deepStrictEqual(effects.splice(0), ["cleanup-skills"]);
assert.strictEqual(payloads.length, 0);

fs.writeFileSync(checksumFile, `${"0".repeat(64)} ${installer.api.archiveName}\n`);
const failed = load(path.join(repository, "scripts/install-cli.js"), {
Expand Down
6 changes: 3 additions & 3 deletions scripts/install-wizard.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ PIPPIT_CLI_INSTALL_PACKAGE can override the package/version to install.

Options:
--source HOST Real host identifier reported as host_platform; unknown hosts omit it
Defaults to PIPPIT_CLI_SOURCE when unset; an explicit empty value omits host_platform
Defaults to PIPPIT_CLI_SOURCE, then known host runtime markers; an explicit empty value omits host_platform
-h, --help Show this help without installing, updating, or sending telemetry
`;

Expand Down Expand Up @@ -67,7 +67,7 @@ function main(args = process.argv.slice(2)) {
console.log(INSTALL_HELP);
return;
}
let source = process.env.PIPPIT_CLI_SOURCE || '';
let source;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--source' && i + 1 < args.length && !args[i + 1].startsWith('--')) {
source = args[++i];
Expand All @@ -79,7 +79,7 @@ function main(args = process.argv.slice(2)) {
return;
}
}
source = source.trim();
if (source !== undefined) source = source.trim();
const pkg = installPackage();
const installed = getGloballyInstalledVersion();
if (installed) {
Expand Down
53 changes: 44 additions & 9 deletions scripts/install-wizard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ assert.strictEqual(installPackage(), `${DEFAULT_PKG}@0.0.26`);
// Exercise install attribution without npm, Skill writes, or network requests.
const fs = require('fs');
const vm = require('vm');
function installFixture(args, source, retry = false) {
const reports = [], installs = [];
const proc = { argv: [], env: source === undefined ? {} : { PIPPIT_CLI_SOURCE: source } };
function installFixture(args, source, retry = false, environment = {}) {
const reports = [], installs = [], payloads = [];
const proc = { argv: [], env: { ...environment, ...(source === undefined ? {} : { PIPPIT_CLI_SOURCE: source }) } };
const module = { exports: {} };
let skillCalls = 0;
const modules = {
Expand All @@ -30,14 +30,17 @@ function installFixture(args, source, retry = false) {
'./skills': { DEFAULT_PKG, installGlobalPackageSkills: () => {
if (retry && skillCalls++ === 0) throw new Error('missing skills');
} },
'./telemetry': { reportBundledSkillTelemetry: (...args) => reports.push(args) },
'./telemetry': { reportBundledSkillTelemetry: (event, source, host) => {
loadTelemetry(proc.env, payloads).reportBundledSkillTelemetry(event, source, host);
reports.push([event, source, payloads[0].host_platform || '']);
} },
};
vm.runInNewContext(fs.readFileSync(require.resolve('./install-wizard'), 'utf8'), {
module, process: proc, console: { log() {}, error() {} },
require: name => modules[name] || require(name),
});
module.exports.main(args);
return { reports, installs, proc };
return { reports, installs, proc, payloads };
}
for (const [args, env, want] of [
[[], undefined, ''], [[], ' workbuddy ', 'workbuddy'],
Expand All @@ -57,16 +60,20 @@ for (const args of [['--help'], ['--source'], ['--unknown']]) {
}

// Verify the HTTP payload, including omission and telemetry opt-out.
function telemetryFixture(source, disabled) {
const payloads = [];
function loadTelemetry(env, payloads) {
const request = () => ({ on() {}, end: body => payloads.push(JSON.parse(body)) });
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('./telemetry'), 'utf8'), {
module, URL, Buffer, console,
process: { env: { PIPPIT_CLI_DISABLE_TELEMETRY: disabled } },
process: { env },
require: name => ['http', 'https'].includes(name) ? { request } : require(name),
});
module.exports.reportBundledSkillTelemetry('install', 'npm_install', source);
return module.exports;
}
function telemetryFixture(source, disabled) {
const payloads = [];
loadTelemetry({ PIPPIT_CLI_DISABLE_TELEMETRY: disabled }, payloads)
.reportBundledSkillTelemetry('install', 'npm_install', source);
return payloads;
}
for (const source of [undefined, '', ' ', ' workbuddy ', 'another-host']) {
Expand All @@ -81,3 +88,31 @@ for (const source of [undefined, '', ' ', ' workbuddy ', 'another-host']) {
}
assert.strictEqual(telemetryFixture('workbuddy', '1').length, 0);
console.log('Install source forwarding, precedence, omission, retry and telemetry payload checks passed');

// Exercise the actual install entry through JSON serialization; no real installation/network.
for (const [args, env, want] of [
[[], { CODEX_THREAD_ID: 'private-id' }, 'codex'],
[[], { CODEX_SESSION_ID: 'private-id', CODEX_THREAD_ID: 'another-id' }, 'codex'],
[[], { CLAUDECODE: '1' }, 'claude_code'],
[[], { CURSOR_AGENT: '1' }, 'cursor'],
[[], { GEMINI_CLI: 'true' }, 'gemini_cli'],
[[], { PIPPIT_CLI_SOURCE: ' workbuddy ', CODEX_THREAD_ID: 'private-id' }, 'workbuddy'],
[['--source= doubao_office '], { PIPPIT_CLI_SOURCE: 'workbuddy', CODEX_THREAD_ID: 'private-id' }, 'doubao_office'],
[['--source', ''], { PIPPIT_CLI_SOURCE: 'workbuddy', CODEX_THREAD_ID: 'private-id' }, undefined],
[['--source= '], { CODEX_THREAD_ID: 'private-id' }, undefined],
[[], { PIPPIT_CLI_SOURCE: ' ', CODEX_THREAD_ID: 'private-id' }, 'codex'],
[[], { CODEX_THREAD_ID: 'private-id', CLAUDECODE: '1' }, undefined],
[[], { CLAUDECODE: '0', CURSOR_AGENT: ' FALSE ', GEMINI_CLI: '' }, undefined],
[[], {}, undefined],
]) {
const { payloads } = installFixture(args, undefined, false, env);
assert.strictEqual(payloads.length, 2);
for (const payload of payloads) {
assert.strictEqual(payload.host_platform, want);
assert.strictEqual(payload.source, 'npx_install');
assert.strictEqual(payload.event, 'install');
assert.strictEqual(payload.platform, process.platform);
assert(!JSON.stringify(payload).includes('private-id'));
}
}
console.log('Install runtime attribution and serialized payload regression checks passed');
2 changes: 1 addition & 1 deletion scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ function install({ cliOnly = false } = {}) {
if (!cliOnly) {
if (process.env.PIPPIT_CLI_SKIP_SKILLS !== "1") {
installSkillsFromRoot(ROOT);
reportBundledSkillTelemetry("install", "npm_install", process.env.PIPPIT_CLI_SOURCE);
reportBundledSkillTelemetry("install", "npm_install");
} else {
cleanupLegacyGlobalSkills();
}
Expand Down
16 changes: 16 additions & 0 deletions scripts/telemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,26 @@ function telemetryBaseURL() {
return DEFAULT_BASE_URL;
}

// Keep the same attribution rules as native CLI common.DetectHostSource.
function resolveHostSource(explicit) {
if (explicit !== undefined) return explicit.trim();
if ((process.env.PIPPIT_CLI_SOURCE || "").trim()) return process.env.PIPPIT_CLI_SOURCE.trim();
const hosts = new Set();
for (const [key, host] of [
["CODEX_THREAD_ID", "codex"], ["CODEX_SESSION_ID", "codex"],
["CLAUDECODE", "claude_code"], ["CURSOR_AGENT", "cursor"], ["GEMINI_CLI", "gemini_cli"],
]) {
const value = (process.env[key] || "").trim().toLowerCase();
if (value && value !== "0" && value !== "false") hosts.add(host);
}
return hosts.size === 1 ? [...hosts][0] : "";
}

function reportBundledSkillTelemetry(event, source, hostPlatform) {
if (process.env.PIPPIT_CLI_DISABLE_TELEMETRY === "1") {
return;
}
hostPlatform = resolveHostSource(hostPlatform);
for (const skillName of SKILL_NAMES) {
reportSkillTelemetry({
event,
Expand Down
Loading