pi-profile-switch 0.9.0 → 0.9.1

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
@@ -36,9 +36,9 @@ Profiles live in two directories, with one JSON file per profile:
36
36
 
37
37
  Create or change a profile by editing or creating a `<name>.json` file directly — schema: [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
38
38
 
39
- You can also configure profiles conversationally: the package ships a **`profile-config`** skill (distributed to `<agentDir>/skills/profile-config/` on install) that guides the agent to clarify requirements, discover resources, and write or remove profile files. Profiles created with a `skills` list include `"profile-config"` by default (unless explicitly opted out or covered by a wildcard like `"*"`), keeping configuration available after switching. Details: [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md).
39
+ You can also configure profiles conversationally: the package ships a **`profile-config`** skill (distributed to `<agentDir>/skills/profile-config/` — best-effort on install, and guaranteed in place at every launcher startup) that guides the agent to clarify requirements, discover resources, and write or remove profile files. Profiles created with a `skills` list include `"profile-config"` by default (unless explicitly opted out or covered by a wildcard like `"*"`), keeping configuration available after switching. Details: [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md).
40
40
 
41
- On install, pi-profile-switch seeds the global `profiles/` directory with a starter **`ask`** profile (`ask.json`) — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
41
+ pi-profile-switch seeds the global `profiles/` directory with a starter **`ask`** profile (`ask.json`) — best-effort on install, and guaranteed in place at every launcher startup — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
42
42
 
43
43
  ```json
44
44
  {
package/README.zh-CN.md CHANGED
@@ -18,7 +18,7 @@ npm install -g pi-profile-switch
18
18
  # 使用内建 default profile 启动(全量资源,等同原生 Pi)
19
19
  pi-profile
20
20
 
21
- # 使用安装时播种的只读 ask profile 启动
21
+ # 使用 starter 只读 ask profile 启动
22
22
  pi-profile ask
23
23
 
24
24
  # -- 后面的参数原样传给 pi
@@ -36,9 +36,9 @@ profile 保存在两个目录中,每个 profile 对应一个独立 JSON 文件
36
36
 
37
37
  直接编辑或新建 `<name>.json` 即可创建或修改 profile——schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
38
38
 
39
- 你也可以通过对话让 agent 帮你配置:随包附带的 **`profile-config`** skill(安装时分发至 `<agentDir>/skills/profile-config/`)会指导 agent 澄清需求、发现资源并读写 profile 文件。按约定,生成声明了 `skills` 的 profile 时默认包含 `"profile-config"`(除非明确排除或已被 `*` 等 glob 覆盖),确保切换到新 profile 后仍可持续对话配置。详情见 [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md)。
39
+ 你也可以通过对话让 agent 帮你配置:随包附带的 **`profile-config`** skill(安装时 best-effort 分发、launcher 启动时保证就位,位于 `<agentDir>/skills/profile-config/`)会指导 agent 澄清需求、发现资源并读写 profile 文件。按约定,生成声明了 `skills` 的 profile 时默认包含 `"profile-config"`(除非明确排除或已被 `*` 等 glob 覆盖),确保切换到新 profile 后仍可持续对话配置。详情见 [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md)。
40
40
 
41
- 安装时,pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** profile(`ask.json`)——只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
41
+ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** profile(`ask.json`)——安装时 best-effort、launcher 启动时保证就位——它是只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
42
42
 
43
43
  ```json
44
44
  {
package/bin/pi-profile.ts CHANGED
@@ -21,11 +21,20 @@ import { spawnPi } from "../src/launcher/spawn.ts";
21
21
  import { McpConfigError, MissingMcpAdapterError } from "../src/mcp-config.ts";
22
22
  import { CatalogError } from "../src/profile-catalog.ts";
23
23
  import { ActivationError } from "../src/profile-resolver.ts";
24
+ import { ensureStarterAssets } from "../src/starter-assets.ts";
24
25
  import { generateRuntimeDir } from "../src/settings-generator.ts";
25
26
 
26
27
  try {
27
28
  const args = parseLauncherArgs(process.argv.slice(2));
28
29
  const agentDir = getAgentDir();
30
+ // Starter assets (seed profile + profile-config skill) are ensured before
31
+ // initial profile resolution so the seed is visible to this launch's
32
+ // resolution and /profile list. Best-effort: ensure failures never block
33
+ // the launch (same pattern as the sweep below).
34
+ const starterAssets = await ensureStarterAssets({ agentDir });
35
+ for (const warning of starterAssets.warnings) {
36
+ console.error(`pi-profile: warning: ${warning}`);
37
+ }
29
38
  // Fails before spawning when the profile is unknown or cannot activate.
30
39
  // --approve/--no-approve are consumed here as a one-run trust input.
31
40
  const { plan, discovery, projectDir, warnings } = await resolveInitialProfile(args.profile, {
@@ -1,9 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * postinstall: seed the global catalog with the default starter profile,
4
- * and distribute the profile-config skill to the agent skills directory.
3
+ * postinstall: best-effort early seeding of the global catalog with the
4
+ * default starter profile, and distribution of the profile-config skill to
5
+ * the agent skills directory.
5
6
  *
6
7
  * Runs at package install time (`npm install pi-profile-switch` / `pi install`).
8
+ *
9
+ * Role: this hook is an early optimization, NOT the sole distribution
10
+ * channel. npm v12+ blocks dependency lifecycle scripts by default
11
+ * (allowScripts), so installs that skip this hook are covered by the
12
+ * launcher instead: `bin/pi-profile.ts` calls the runtime ensure
13
+ * (src/starter-assets.ts) on every launch, before resolving the initial
14
+ * profile. The authoritative behavior contract for both assets lives in
15
+ * openspec/specs/profile-catalog/spec.md ("播种 starter profile" and
16
+ * "分发 profile-config skill"); keep this script's rules in sync with
17
+ * src/starter-assets.ts, which is the single TS implementation.
18
+ *
7
19
  * Idempotent and conservative:
8
20
  * - Writes the shipped `examples/ask.json` starter profile to the
9
21
  * profiles dir ONLY when no .json profile exists there yet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Runtime ensure of starter assets: seeds the starter profile into global
3
+ * profiles/ if no profile exists yet, and distributes/syncs the profile-config
4
+ * skill into the user's agent skills directory.
5
+ *
6
+ * Idempotent, safe against concurrent launches, and downgrades IO errors
7
+ * to warnings without throwing.
8
+ */
9
+
10
+ import { constants } from "node:fs";
11
+ import { copyFile, mkdir, readFile, readdir } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
+
17
+ import { getGlobalProfilesDir } from "./workspace.ts";
18
+
19
+ export interface StarterAssetFileResult {
20
+ /** 目标文件绝对路径 */
21
+ path: string;
22
+ /** 本次调用是否发生了写入 */
23
+ written: boolean;
24
+ }
25
+
26
+ export interface StarterAssetsResult {
27
+ profile: StarterAssetFileResult;
28
+ skill: StarterAssetFileResult;
29
+ /** 人类可读的降级警告;为空表示全部成功或无操作 */
30
+ warnings: string[];
31
+ }
32
+
33
+ export interface EnsureStarterAssetsOptions {
34
+ /** 默认 getGlobalProfilesDir();测试注入 */
35
+ globalProfilesDir?: string;
36
+ /** 默认 Pi 的 getAgentDir();测试注入 */
37
+ agentDir?: string;
38
+ /** 默认由 import.meta.url 定位包根;测试注入 */
39
+ packageRoot?: string;
40
+ }
41
+
42
+ async function hasAnyJsonProfiles(dir: string): Promise<boolean> {
43
+ try {
44
+ const entries = await readdir(dir);
45
+ return entries.some((name) => name.endsWith(".json"));
46
+ } catch (error: unknown) {
47
+ const err = error as NodeJS.ErrnoException;
48
+ if (err.code === "ENOENT") {
49
+ return false;
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ export async function ensureStarterAssets(options?: EnsureStarterAssetsOptions): Promise<StarterAssetsResult> {
56
+ const defaultPackageRoot = fileURLToPath(new URL("..", import.meta.url));
57
+ const packageRoot = options?.packageRoot ? path.resolve(options.packageRoot) : defaultPackageRoot;
58
+ const globalProfiles = options?.globalProfilesDir ? path.resolve(options.globalProfilesDir) : getGlobalProfilesDir();
59
+ const agent = options?.agentDir ? path.resolve(options.agentDir) : getAgentDir();
60
+
61
+ const profileTemplate = path.join(packageRoot, "examples", "ask.json");
62
+ const targetProfilePath = path.join(globalProfiles, "ask.json");
63
+
64
+ const skillTemplate = path.join(packageRoot, "skills", "profile-config", "SKILL.md");
65
+ const targetSkillDir = path.join(agent, "skills", "profile-config");
66
+ const targetSkillPath = path.join(targetSkillDir, "SKILL.md");
67
+
68
+ const warnings: string[] = [];
69
+ const profileResult: StarterAssetFileResult = {
70
+ path: targetProfilePath,
71
+ written: false,
72
+ };
73
+ const skillResult: StarterAssetFileResult = {
74
+ path: targetSkillPath,
75
+ written: false,
76
+ };
77
+
78
+ // 1. Starter profile seeding
79
+ try {
80
+ const hasJson = await hasAnyJsonProfiles(globalProfiles);
81
+ if (!hasJson) {
82
+ await mkdir(globalProfiles, { recursive: true });
83
+ try {
84
+ await copyFile(profileTemplate, targetProfilePath, constants.COPYFILE_EXCL);
85
+ profileResult.written = true;
86
+ } catch (error: unknown) {
87
+ const err = error as NodeJS.ErrnoException;
88
+ if (err.code === "EEXIST") {
89
+ // Lost a create race (concurrent launch); winner stands
90
+ profileResult.written = false;
91
+ } else {
92
+ throw error;
93
+ }
94
+ }
95
+ }
96
+ } catch (error: unknown) {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ warnings.push(`could not seed starter profile: ${message}`);
99
+ }
100
+
101
+ // 2. Profile-config skill distribution & sync
102
+ try {
103
+ const templateContent = await readFile(skillTemplate, "utf8");
104
+ let needsWrite = true;
105
+ try {
106
+ const existingContent = await readFile(targetSkillPath, "utf8");
107
+ if (existingContent === templateContent) {
108
+ needsWrite = false;
109
+ }
110
+ } catch (error: unknown) {
111
+ const err = error as NodeJS.ErrnoException;
112
+ if (err.code !== "ENOENT") {
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ if (needsWrite) {
118
+ await mkdir(targetSkillDir, { recursive: true });
119
+ await copyFile(skillTemplate, targetSkillPath);
120
+ skillResult.written = true;
121
+ }
122
+ } catch (error: unknown) {
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ warnings.push(`could not distribute profile-config skill: ${message}`);
125
+ }
126
+
127
+ return {
128
+ profile: profileResult,
129
+ skill: skillResult,
130
+ warnings,
131
+ };
132
+ }