create-pmos 0.13.0 → 0.15.0

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.
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+ /*
3
+ * Claude profile descriptors — a PURE reader (initiative `workspace-profiles`, PRD R1/R2/R4/R6/R7).
4
+ *
5
+ * The workspace lets the PM name a profile when a session's process is created. This module is the
6
+ * half of that feature which decides WHAT IS TRUE: it reads `<home>/.claude/profiles/*.json` and
7
+ * reports what it found. It never spawns anything, never writes, and never touches the network.
8
+ *
9
+ * WHAT THIS MODULE IS NOT ALLOWED TO IMPLY. PMOS holds no credential, runs no MCP client, and
10
+ * cannot ask a running `claude` what configuration it is really using. So every value here is
11
+ * "what a file on this machine declares", never "what is true of the world". The naming below is
12
+ * deliberately clumsy in places (`declared`, `recorded`) because the shorter words all read as
13
+ * verified facts.
14
+ *
15
+ * HONESTY BY CONSTRUCTION — readProfiles() returns a FOUR-way discrimination, not a boolean.
16
+ * These are four different facts about the world and collapsing any pair of them produces a
17
+ * specific, known lie:
18
+ *
19
+ * { ok:true, absent:true, profiles:[], broken:[] } no ~/.claude/profiles directory.
20
+ * A calm, stated absence. Nothing was read because there was nothing to read. The picker
21
+ * renders the path and says so. It must NOT render `work`/`personal` chips: those are the
22
+ * names the PM's machine is expected to carry, not names PMOS ships, and drawing them here
23
+ * is the PRD's stated FAIL — a picker appears, a choice is made, and nothing was applied.
24
+ *
25
+ * { ok:true, absent:false, profiles:[], broken:[] } the directory exists and is empty.
26
+ * A SUCCESSFUL READ OF NOTHING. Same consequence for the PM, different fact: the directory
27
+ * was created and left empty rather than never created. Calm, not red.
28
+ *
29
+ * { ok:true, absent:false, profiles:[…], broken:[…] } some descriptors read, some did not.
30
+ * R4: one bad file must not take the others down. The good ones are still offered; each bad
31
+ * one is named with its filename and its reason so it renders RED. Note that `profiles` can
32
+ * be EMPTY while `broken` is not — every descriptor on the machine is malformed. A consumer
33
+ * that tests only `profiles.length` renders that as the calm empty state above, which
34
+ * reports a failure as an emptiness. Check `broken.length` before drawing calm prose.
35
+ *
36
+ * { ok:false, absent:false, profiles:[], error:'…' } the directory could not be read.
37
+ * Permissions, or a FILE sitting where the directory should be. This is the distinction the
38
+ * module exists to protect: a missing directory and an unreadable one are different facts.
39
+ * Defaulting an unreadable directory to "no profiles configured" would state a capability
40
+ * claim the data does not support — it would tell the PM their machine has no profiles when
41
+ * what actually happened is that PMOS could not look.
42
+ *
43
+ * `dir` is returned in every case, including the failures, because R2 and R10 both require the
44
+ * surface to NAME the path it read or failed to read. An absence with no path is an assertion the
45
+ * PM cannot check.
46
+ */
47
+ const fs = require('fs');
48
+ const path = require('path');
49
+
50
+ // Where the agent CLI keeps them. PMOS does not define this format and never writes here (PRD
51
+ // non-goal 3) — it is read-only to us, so an unexpected key is ignored rather than migrated.
52
+ const PROFILE_SUBDIR = ['.claude', 'profiles'];
53
+
54
+ function profilesDir(homeDir) { return path.join(homeDir, ...PROFILE_SUBDIR); }
55
+
56
+ // Errors are shown to a human who has to go fix a file, so carry the code AND the message. Both
57
+ // halves earn their place: the code says what kind of problem it is, the message says which path.
58
+ // Node already prefixes most fs messages with the code, so only prepend it when it is missing —
59
+ // "EISDIR: EISDIR: illegal operation" is noise in a line a PM is meant to act on.
60
+ function describeErr(e) {
61
+ const msg = String((e && e.message) || e);
62
+ const code = e && e.code ? String(e.code) : '';
63
+ return (code && !msg.startsWith(code) ? `${code}: ${msg}` : msg).slice(0, 200);
64
+ }
65
+
66
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
67
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
68
+
69
+ /*
70
+ * Validate one parsed descriptor against R1's table. Returns `{ profile }` or `{ error }`.
71
+ *
72
+ * The rule running through every branch: A KEY THAT IS PRESENT BUT WRONG-TYPED MAKES THE
73
+ * DESCRIPTOR UNREADABLE. It is never coerced and never dropped. The reason is that both softer
74
+ * options lie in the PM's favour. Coercing `mcp: "jira"` into `["jira"]` invents a declaration the
75
+ * file did not make; dropping it renders "no mcp servers declared in this profile" over a file
76
+ * that plainly declared one. Rejecting is loud, names the file, and leaves the other descriptors
77
+ * alone (R4) — the PM gets a fixable error instead of a quiet wrong answer.
78
+ *
79
+ * A key that is ABSENT is a different matter: R1 forbids substituting a default, so absence is
80
+ * carried through as `null` (account) or `[]` (mcp) for the consumer to state, not to fill in.
81
+ */
82
+ function validate(raw, stem) {
83
+ if (!isPlainObject(raw)) {
84
+ const found = raw === null ? 'null' : Array.isArray(raw) ? 'an array' : typeof raw;
85
+ return { error: `is not a JSON object — found ${found}` };
86
+ }
87
+
88
+ // `id` is the identity the pane line, the session record and the descriptor path all cite, so it
89
+ // is the one key with no tolerated absence. Falling back to the filename stem would look
90
+ // harmless and be exactly the wrong thing: PMOS would be manufacturing the identity it then
91
+ // displays as read from the file, and a later rename would silently change which profile a
92
+ // session claims to have run under.
93
+ if (!has(raw, 'id')) {
94
+ return { error: 'declares no `id` — PMOS will not substitute the filename, because the id is the identity the pane and the session record cite' };
95
+ }
96
+ if (typeof raw.id !== 'string' || !raw.id) return { error: '`id` is present but is not a non-empty string' };
97
+ if (raw.id !== stem) {
98
+ return { error: `declares id "${raw.id}" but is filed as ${stem}.json — the descriptor's identity disagrees with its filename` };
99
+ }
100
+
101
+ let label = raw.id; // R1: display name, falls back to `id` when absent
102
+ if (has(raw, 'label')) {
103
+ if (typeof raw.label !== 'string' || !raw.label.trim()) return { error: '`label` is present but is not a non-empty string' };
104
+ label = raw.label;
105
+ }
106
+
107
+ // Displayed VERBATIM and always attributed to the file (R1). PMOS cannot verify an account
108
+ // string and does not try; if it is wrong on disk it is wrong on screen, which is why every
109
+ // surface that shows it also shows where it came from. Absent stays null — "not declared" is a
110
+ // thing the consumer can say, and is not the same as an empty account name.
111
+ let account = null;
112
+ if (has(raw, 'account')) {
113
+ if (typeof raw.account !== 'string') return { error: '`account` is present but is not a string' };
114
+ account = raw.account;
115
+ }
116
+
117
+ // R7: these are names a file declares, never servers PMOS reached. Absent and `[]` collapse to
118
+ // the same array on purpose — both mean "this descriptor declares no MCP servers", which is one
119
+ // fact with one honest phrasing. What must NOT collapse into it is a malformed `mcp`, above.
120
+ let mcp = [];
121
+ if (has(raw, 'mcp')) {
122
+ if (!Array.isArray(raw.mcp) || raw.mcp.some((x) => typeof x !== 'string')) {
123
+ return { error: '`mcp` is present but is not an array of server names' };
124
+ }
125
+ mcp = raw.mcp.slice();
126
+ }
127
+
128
+ // The only key with a mechanical effect: it becomes the environment overlay at process creation
129
+ // (R3). `null` (key absent) and `{}` (declared empty) are kept DISTINCT here even though both
130
+ // apply nothing, because the reader's job is to report the file faithfully; profileLine() is
131
+ // where they converge, and it converges them on the sentence that says nothing was applied.
132
+ let env = null;
133
+ if (has(raw, 'env')) {
134
+ if (!isPlainObject(raw.env)) return { error: '`env` is present but is not an object of environment variables' };
135
+ for (const k of Object.keys(raw.env)) {
136
+ if (typeof raw.env[k] !== 'string') {
137
+ return { error: `\`env.${k}\` is not a string — PMOS will not coerce a value into an environment variable it was not given` };
138
+ }
139
+ }
140
+ env = { ...raw.env };
141
+ }
142
+
143
+ return { profile: { id: raw.id, label, account, mcp, env } };
144
+ }
145
+
146
+ /*
147
+ * Read every descriptor under `<homeDir>/.claude/profiles`.
148
+ *
149
+ * `homeDir` is a parameter rather than `os.homedir()` read inside, which is what makes this
150
+ * testable against real fixture directories instead of against a mock of the filesystem. A caller
151
+ * with no home directory gets a stated failure; guessing one would be the same class of invention
152
+ * this whole module refuses.
153
+ */
154
+ function readProfiles(homeDir) {
155
+ if (typeof homeDir !== 'string' || !homeDir) {
156
+ return {
157
+ ok: false, absent: false, dir: null, profiles: [], broken: [],
158
+ error: 'no home directory was given — PMOS will not guess where .claude/profiles lives',
159
+ };
160
+ }
161
+ const dir = profilesDir(homeDir);
162
+
163
+ let names;
164
+ try {
165
+ names = fs.readdirSync(dir);
166
+ } catch (e) {
167
+ // THE DISTINCTION THIS MODULE EXISTS FOR. ENOENT is "there is nothing here", which is calm and
168
+ // true. Everything else — EACCES, ENOTDIR, EIO — is "PMOS could not look", which is a failure
169
+ // and must read red. Reporting the second as the first would tell the PM their machine has no
170
+ // profiles configured when PMOS never got far enough to know.
171
+ if (e && e.code === 'ENOENT') return { ok: true, absent: true, dir, profiles: [], broken: [], error: null };
172
+ if (e && e.code === 'ENOTDIR') {
173
+ return { ok: false, absent: false, dir, profiles: [], broken: [], error: `${dir} is not a directory — a file is sitting where the profiles directory should be` };
174
+ }
175
+ return { ok: false, absent: false, dir, profiles: [], broken: [], error: `${dir} could not be read — ${describeErr(e)}` };
176
+ }
177
+
178
+ const profiles = [];
179
+ const broken = [];
180
+ // Sorted so the picker's chip order is stable across reads. Directory order is not.
181
+ for (const name of names.slice().sort()) {
182
+ if (!name.endsWith('.json')) continue; // not a descriptor at all — not a failed one either
183
+ const file = path.join(dir, name);
184
+ const stem = name.slice(0, -'.json'.length);
185
+
186
+ let text;
187
+ try {
188
+ text = fs.readFileSync(file, 'utf8');
189
+ } catch (e) {
190
+ // Includes EISDIR: a DIRECTORY named `something.json`. Reported rather than skipped — it
191
+ // occupies the name a descriptor would have, so silently passing over it would leave the PM
192
+ // looking at a picker missing a chip with nothing anywhere saying why.
193
+ broken.push({ file: name, path: file, error: `could not be read — ${describeErr(e)}` });
194
+ continue;
195
+ }
196
+
197
+ let raw;
198
+ try {
199
+ raw = JSON.parse(text);
200
+ } catch (e) {
201
+ broken.push({ file: name, path: file, error: `is not valid JSON — ${String((e && e.message) || e).slice(0, 140)}` });
202
+ continue;
203
+ }
204
+
205
+ const v = validate(raw, stem);
206
+ if (v.error) { broken.push({ file: name, path: file, error: v.error }); continue; }
207
+ profiles.push({ ...v.profile, path: file });
208
+ }
209
+
210
+ // ok:true means THE DIRECTORY was read. It does not mean every file in it was. R4 requires those
211
+ // to be separable, so a malformed descriptor lands in `broken` and leaves its neighbours usable.
212
+ return { ok: true, absent: false, dir, profiles, broken, error: null };
213
+ }
214
+
215
+ /*
216
+ * The one line naming what a pane is running under — PRD R6, verbatim sentences.
217
+ *
218
+ * DIVERGENCE FROM THE DESIGN, recorded rather than glossed. `HANDOFF.md` §3 asks for
219
+ * `profile: claude-work · mcp: jira confluence slack gworkspace`, and `mcp: none` for the other
220
+ * case. This returns the PRD's R6/R7 wording instead, for two reasons the PRD states and the
221
+ * design does not address:
222
+ * - `mcp: none` reads as a checked fact about the world. PMOS has no MCP client and never
223
+ * contacted anything; the only thing it knows is what the descriptor declared. R7 forbids the
224
+ * phrase outright, and the honest replacement is "no mcp servers declared in this profile".
225
+ * - the design's line has no way to say "the profile declares no environment overlay", which is
226
+ * the case where the PM is most likely to believe a choice took effect when nothing did.
227
+ * If the literal design string is wanted it is a PM ruling, not an implementation detail.
228
+ *
229
+ * "applied N environment variables" IS A CLAIM ABOUT THE SPAWN, not about the file. This function
230
+ * is a formatter and cannot verify it. The caller must therefore only pass a descriptor for a
231
+ * session PMOS actually created with that descriptor's overlay (R3); rendering this line from a
232
+ * descriptor that was merely selected would state an application that never happened.
233
+ *
234
+ * The three no-descriptor cases are distinguished by the ARGUMENT'S OWN TYPE, because they survive
235
+ * a JSON round-trip differently and a session record is JSON:
236
+ * undefined / key absent → "not recorded" (an orphan tmux pane, or a record persisted before
237
+ * this feature existed — inventing a profile for it
238
+ * would be a lie about the past)
239
+ * null → "none was chosen" (PMOS started it and recorded that nothing was
240
+ * picked; `null` is written explicitly so it does
241
+ * not decay into the case above)
242
+ * A bare id STRING is rejected on purpose: what was applied is a fact about the descriptor, so a
243
+ * caller holding only an id must resolve it through readProfiles() first (R9 — a respawn whose
244
+ * descriptor has since been deleted must not substitute another).
245
+ */
246
+ function profileLine(profile, read) {
247
+ if (profile === undefined) return 'profile: not recorded — PMOS did not start this pane';
248
+
249
+ if (profile === null) {
250
+ // Only claim the machine has none configured when the read in hand actually says so. A read
251
+ // that failed, or one that found broken descriptors, does not support that sentence — the
252
+ // profiles may well be there and unreadable.
253
+ const noneConfigured = !!read && read.ok === true &&
254
+ (read.absent === true ||
255
+ ((read.profiles || []).length === 0 && (read.broken || []).length === 0));
256
+ return noneConfigured
257
+ ? 'no profile — none was chosen (no profiles are configured on this machine)'
258
+ : 'no profile — none was chosen for this session';
259
+ }
260
+
261
+ if (!isPlainObject(profile)) return 'profile: unreadable — the recorded value is not a profile descriptor';
262
+
263
+ // `label` is the declared display name (R1) but `id` is the traceable one: it is the descriptor's
264
+ // filename stem, so a reader can go check the file. Label wins where present because the spec
265
+ // says so; where it is absent the id carries the line.
266
+ const name = (typeof profile.label === 'string' && profile.label) ? profile.label
267
+ : (typeof profile.id === 'string' && profile.id) ? profile.id : null;
268
+ if (!name) return 'profile: unreadable — the recorded descriptor names no profile';
269
+
270
+ const n = isPlainObject(profile.env) ? Object.keys(profile.env).length : 0;
271
+ if (n === 0) {
272
+ // Covers both "no `env` key" and "`env: {}`". They differ on disk and are identical to the PM:
273
+ // nothing was applied. Saying "applied 0 environment variables" would be the precise phrasing
274
+ // most likely to be read as success, so R6 replaces it with a sentence about consequence.
275
+ return `profile: ${name} — the profile declares no environment overlay; this session runs with the workspace's default environment`;
276
+ }
277
+
278
+ const vars = `applied ${n} environment variable${n === 1 ? '' : 's'}`;
279
+ const mcp = (Array.isArray(profile.mcp) && profile.mcp.length)
280
+ ? `mcp declared by the profile: ${profile.mcp.join(' ')}`
281
+ : 'no mcp servers declared in this profile';
282
+ return `profile: ${name} — ${vars} · ${mcp}`;
283
+ }
284
+
285
+ module.exports = { readProfiles, profileLine, profilesDir, PROFILE_SUBDIR };