create-pmos 0.14.0 → 0.16.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.
- package/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +157 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +14 -4
- package/workspace/public/index.html +79 -279
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +1072 -31
- package/workspace/public/app.js +0 -3450
- package/workspace/public/styles.css +0 -1444
package/workspace/lib/layout.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* so a layout the client can produce and a layout the server will store can never disagree.
|
|
7
7
|
*
|
|
8
8
|
* A layout is a NAMED STATE TUPLE. The grid template is COMPUTED from it (gridFor) rather than
|
|
9
|
-
* hand-written per layout, because
|
|
9
|
+
* hand-written per layout, because that is exactly where a
|
|
10
10
|
* hand-written template drifts: a column count that no longer matches its area row produces an
|
|
11
11
|
* unusable window, and the PM's live agent sessions are inside it.
|
|
12
12
|
*
|
|
@@ -24,24 +24,41 @@
|
|
|
24
24
|
else root.PMOSLayout = api;
|
|
25
25
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
26
26
|
|
|
27
|
+
/* workspace-viewer-column — `reader` is RETIRED from the tuple.
|
|
28
|
+
*
|
|
29
|
+
* It used to hold four positions and decided where a document rendered. Documents, images and
|
|
30
|
+
* the summoned surfaces now open as tabs in the terminal column, so there is no longer a pane to
|
|
31
|
+
* position: `#paneDoc`, `#railSplit` and `libSplit` went with it, and `.rail` is the detail panel
|
|
32
|
+
* alone. Keeping the slot and merely emptying it was the alternative and is the worse one — it
|
|
33
|
+
* would leave document-shaped content two possible homes and no rule for which.
|
|
34
|
+
*
|
|
35
|
+
* What survives is the DETAIL PANEL's column, which still needs a width and keeps its drag
|
|
36
|
+
* handle — `readW` is renamed `panelW` and old values fall back to it, since it sized the same
|
|
37
|
+
* column. What does not survive is its POSITION: the panel now always sits between the structure
|
|
38
|
+
* column and the terminal, because the four positions existed to place a document.
|
|
39
|
+
*
|
|
40
|
+
* So the tuple is `{side, split, sideW, panelW, folds}` rather than seven fields.
|
|
41
|
+
*/
|
|
27
42
|
const SIDE = ['left', 'right', 'off'];
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
const SPLIT = ['off', 'on'];
|
|
44
|
+
// `doc` retired with the pane it folded. `sess` was already retired (R13) and is likewise ignored
|
|
45
|
+
// rather than migrated — a stored value for either resolves harmlessly through sanitize.
|
|
46
|
+
const FOLD_KEYS = ['ws', 'lib'];
|
|
47
|
+
const RETIRED_FOLD_KEYS = ['sess', 'doc'];
|
|
31
48
|
|
|
32
49
|
const SIDE_MIN = 160, SIDE_MAX = 520;
|
|
33
|
-
const
|
|
34
|
-
const LIB_MIN = 15, LIB_MAX = 80; // the library/document divider, percent
|
|
35
|
-
const BOTTOM_H = 300; // the reader's height when docked bottom
|
|
36
|
-
const LIB_DEFAULT = 46;
|
|
50
|
+
const PANEL_MIN = 280, PANEL_MAX = 1000; // the detail panel's width, still draggable
|
|
37
51
|
|
|
38
|
-
// `review` is
|
|
39
|
-
//
|
|
52
|
+
// `review` is the DEFAULT — the arrangement a PM who never opens the layout control sees.
|
|
53
|
+
// With the reader retired, the four stock layouts differ in the two axes that remain: whether the
|
|
54
|
+
// structure column is present, which side it sits on, and whether the terminal is split. That is
|
|
55
|
+
// fewer axes than before and they are kept genuinely distinct rather than padded back out to four
|
|
56
|
+
// by inventing differences the PM would not feel.
|
|
40
57
|
const STOCK = [
|
|
41
|
-
{ id: 'focus', name: 'focus', key: 1, side: 'off',
|
|
42
|
-
{ id: 'read', name: 'read', key: 2, side: 'left',
|
|
43
|
-
{ id: 'compare', name: 'compare', key: 3, side: 'left',
|
|
44
|
-
{ id: 'review', name: 'review', key: 4, side: 'left',
|
|
58
|
+
{ id: 'focus', name: 'focus', key: 1, side: 'off', split: 'off', sideW: 262, panelW: 460 },
|
|
59
|
+
{ id: 'read', name: 'read', key: 2, side: 'left', split: 'off', sideW: 214, panelW: 760 },
|
|
60
|
+
{ id: 'compare', name: 'compare', key: 3, side: 'left', split: 'on', sideW: 214, panelW: 420 },
|
|
61
|
+
{ id: 'review', name: 'review', key: 4, side: 'left', split: 'off', sideW: 280, panelW: 560 },
|
|
45
62
|
];
|
|
46
63
|
const DEFAULT_ID = 'review';
|
|
47
64
|
const STOCK_IDS = STOCK.map((l) => l.id);
|
|
@@ -54,7 +71,7 @@
|
|
|
54
71
|
return Number.isFinite(n) ? n : fallback;
|
|
55
72
|
};
|
|
56
73
|
|
|
57
|
-
function defaultFolds() { return { ws: true,
|
|
74
|
+
function defaultFolds() { return { ws: true, lib: true }; }
|
|
58
75
|
function folds(v) {
|
|
59
76
|
const out = defaultFolds();
|
|
60
77
|
if (v && typeof v === 'object') for (const k of FOLD_KEYS) if (typeof v[k] === 'boolean') out[k] = v[k];
|
|
@@ -84,15 +101,19 @@
|
|
|
84
101
|
name: stock ? base.name : name, // stock names are not editable (R5)
|
|
85
102
|
key: stock ? base.key : null, // ⌃1–⌃4 belong to the stock four
|
|
86
103
|
side: oneOf(r.side, SIDE, base ? base.side : 'left'),
|
|
87
|
-
reader: oneOf(r.reader, READER, base ? base.reader : 'right'),
|
|
88
104
|
split: oneOf(r.split, SPLIT, base ? base.split : 'off'),
|
|
89
105
|
sideW: Math.round(clamp(num(r.sideW, base ? base.sideW : 262), SIDE_MIN, SIDE_MAX)),
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
// The detail panel keeps a width and keeps its drag handle. A layout stored before this
|
|
107
|
+
// change has no `panelW`, but it does have the `readW` that used to size the same column —
|
|
108
|
+
// so that is what it falls back to, and the PM's tuning survives the rename instead of
|
|
109
|
+
// snapping to a default. This is the one legacy field genuinely worth carrying.
|
|
110
|
+
panelW: Math.round(clamp(num(r.panelW, num(r.readW, base ? base.panelW : 560)), PANEL_MIN, PANEL_MAX)),
|
|
111
|
+
// `reader`, `readW` and `libSplit` are DROPPED rather than carried. Every layout persisted
|
|
112
|
+
// before workspace-viewer-column still holds all three, and they arrive here on every boot in
|
|
113
|
+
// a live workspace — so the contract that matters is that reading them is harmless, which it
|
|
114
|
+
// is: unknown keys on `r` are simply not copied out. Dropping is deliberate and not silent —
|
|
115
|
+
// a value for a pane that no longer exists cannot be honoured, and carrying it forward would
|
|
116
|
+
// let a later reader resurrect a pane the app can no longer render.
|
|
96
117
|
folds: folds(r.folds),
|
|
97
118
|
lastUsedAt: Math.max(0, Math.round(num(r.lastUsedAt, 0))),
|
|
98
119
|
};
|
|
@@ -130,19 +151,17 @@
|
|
|
130
151
|
/* The computed grid. Returns {columns, rows, areas} as CSS strings.
|
|
131
152
|
INVARIANT: every area row names exactly as many cells as `columns` has tracks. */
|
|
132
153
|
function gridFor(l) {
|
|
133
|
-
const S = l.sideW + 'px',
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
154
|
+
const S = l.sideW + 'px', P = l.panelW + 'px';
|
|
155
|
+
// The detail panel is no longer independently positionable: it always sits between the
|
|
156
|
+
// structure column and the terminal, because that is the order the work flows in — pick the
|
|
157
|
+
// initiative, read its state, act on it. The four positions it used to have existed to place a
|
|
158
|
+
// DOCUMENT, and documents are tabs in the terminal column now. Hiding the panel is the column
|
|
159
|
+
// collapse (a 36px labelled rail), which is client state rather than part of the arrangement.
|
|
140
160
|
const cols = [], names = [];
|
|
141
|
-
if (l.side === 'left')
|
|
142
|
-
|
|
161
|
+
if (l.side === 'left') { cols.push(S); names.push('side'); }
|
|
162
|
+
cols.push(P); names.push('read');
|
|
143
163
|
cols.push('minmax(0,1fr)'); names.push('term');
|
|
144
|
-
if (l.
|
|
145
|
-
if (l.side === 'right') { cols.push(S); names.push('side'); }
|
|
164
|
+
if (l.side === 'right') { cols.push(S); names.push('side'); }
|
|
146
165
|
return { columns: cols.join(' '), rows: 'minmax(0,1fr)', areas: '"' + names.join(' ') + '"' };
|
|
147
166
|
}
|
|
148
167
|
|
|
@@ -156,28 +175,31 @@
|
|
|
156
175
|
The property item 4 actually names — the control that un-hides a pane is itself always
|
|
157
176
|
reachable — is a fact about the DOM (the chip lives in `.bar`, a grid sibling of `#bodyGrid`,
|
|
158
177
|
and the position controls in `.lay-pos` can always restore a pane), and it is asserted where
|
|
159
|
-
it can genuinely fail: ui-test.js walks
|
|
178
|
+
it can genuinely fail: ui-test.js walks every remaining arrangement in a real engine. */
|
|
160
179
|
function hasEscape(l) {
|
|
161
180
|
return gridFor(l).areas.indexOf('term') !== -1;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
183
|
/* R8 — carry the pre-feature client keys into this workspace's `review` so nobody's tuning is
|
|
165
|
-
lost on upgrade.
|
|
184
|
+
lost on upgrade. It read five localStorage values the shipped app wrote:
|
|
166
185
|
rail(on|off) railw(<n>px) libsplit(<n>%) fold-lib(on|off) fold-doc(on|off).
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
|
|
187
|
+
THREE OF THE FIVE NO LONGER HAVE ANYWHERE TO GO, and that is stated rather than quietly
|
|
188
|
+
dropped: `rail: off` positioned a pane that is now always a column (hiding it is the column
|
|
189
|
+
collapse, which is client state and not part of an arrangement); `libsplit` divided a rail that
|
|
190
|
+
no longer has two halves; `fold-doc` folded `#paneDoc`, which no longer exists. Carrying any of
|
|
191
|
+
them would mean writing a value no reader can honour.
|
|
192
|
+
|
|
193
|
+
`railw` still has a real destination — it sized the same column `panelW` now sizes — so it is
|
|
194
|
+
the one width that survives, and `fold-lib` still folds the pane it always folded. A PM
|
|
195
|
+
upgrading from the pre-feature build keeps their column width and their fold; they lose a
|
|
196
|
+
reader position for a reader that is gone. */
|
|
170
197
|
function migrateLegacy(legacy) {
|
|
171
198
|
const g = (k) => (legacy && typeof legacy === 'object' ? legacy[k] : null);
|
|
172
199
|
const review = stockLayout(DEFAULT_ID);
|
|
173
|
-
const railOff = g('rail') === 'off';
|
|
174
|
-
if (railOff) review.reader = 'off';
|
|
175
200
|
const w = num(String(g('railw') || '').replace('px', ''), null);
|
|
176
|
-
if (w !== null) review.
|
|
177
|
-
const ls = num(String(g('libsplit') || '').replace('%', ''), null);
|
|
178
|
-
if (ls !== null) review.libSplit = Math.round(clamp(ls, LIB_MIN, LIB_MAX) * 10) / 10;
|
|
201
|
+
if (w !== null) review.panelW = Math.round(clamp(w, PANEL_MIN, PANEL_MAX));
|
|
179
202
|
if (g('fold-lib') === 'off') review.folds.lib = false;
|
|
180
|
-
if (g('fold-doc') === 'off') review.folds.doc = false;
|
|
181
203
|
const layouts = stockLayouts().map((l) => (l.id === DEFAULT_ID ? review : l));
|
|
182
204
|
return { layouts, currentId: DEFAULT_ID };
|
|
183
205
|
}
|
|
@@ -198,8 +220,8 @@
|
|
|
198
220
|
}
|
|
199
221
|
|
|
200
222
|
return {
|
|
201
|
-
SIDE,
|
|
202
|
-
SIDE_MIN, SIDE_MAX,
|
|
223
|
+
SIDE, SPLIT, FOLD_KEYS, RETIRED_FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
|
|
224
|
+
SIDE_MIN, SIDE_MAX, PANEL_MIN, PANEL_MAX,
|
|
203
225
|
isStock, stockLayout, stockLayouts, layout, sanitize, gridFor, hasEscape, migrateLegacy,
|
|
204
226
|
loadAction,
|
|
205
227
|
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/* mobile-nav — the phone surface's navigation reducer (workspace-mobile-surface).
|
|
2
|
+
*
|
|
3
|
+
* One source of truth for "which screen am I on", used on BOTH sides:
|
|
4
|
+
* - the browser loads it as /lib/mobile-nav.js before app.js (window.PMOSMobileNav)
|
|
5
|
+
* - test.js requires it, so the whole domain is walkable in node without a browser
|
|
6
|
+
* exactly as lib/layout.js is. It was written inline in app.js and moved here because the rubric
|
|
7
|
+
* asks for a pure module and because a reducer that cannot be unit-tested is one whose exclusivity
|
|
8
|
+
* guarantee is only ever checked by eye.
|
|
9
|
+
*
|
|
10
|
+
* DOM-FREE ON PURPOSE. Every function takes a nav state and returns a new one; none touches an
|
|
11
|
+
* element, a media query, or the app's `state`. That is what makes the four properties below
|
|
12
|
+
* decidable without a browser:
|
|
13
|
+
* P1 exactly one column is shown for any screen — `column` is a function of `kind` alone
|
|
14
|
+
* P2 every MODES key has a phone path — the map is iterated, never a literal list
|
|
15
|
+
* P3 back never exits the app and never unwinds into another destination
|
|
16
|
+
* P4 choosing a destination resets it to its root
|
|
17
|
+
*
|
|
18
|
+
* The screen model:
|
|
19
|
+
* { dest, kind, mode, id, dk, title, column }
|
|
20
|
+
* dest one of DESTS — which destination this screen belongs to
|
|
21
|
+
* kind 'list' | 'detail' | 'terminal' | 'doc' | 'more'
|
|
22
|
+
* mode a MODES key, for kind 'list'
|
|
23
|
+
* id the detail id / session id / viewer id
|
|
24
|
+
* dk the DETAIL kind, for kind 'detail'
|
|
25
|
+
* title only for 'terminal' and 'doc', where the name is not derivable
|
|
26
|
+
* column 'side' | 'term' | 'rail' — determined by `kind` and by nothing else
|
|
27
|
+
*
|
|
28
|
+
* `column` is the whole of the exclusivity guarantee: the stylesheet shows exactly one of the three
|
|
29
|
+
* column nodes per value, so P1 is a property of the CSS rather than something this reducer has to
|
|
30
|
+
* remember to enforce. The columns are never re-parented and never rebuilt, which is what makes the
|
|
31
|
+
* xterm instance and its PTY socket survive a push, a pop and a destination switch structurally.
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
(function (root, factory) {
|
|
35
|
+
const api = factory();
|
|
36
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
37
|
+
else root.PMOSMobileNav = api;
|
|
38
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
39
|
+
|
|
40
|
+
// What a LIST screen is called, keyed by mode. This mirrors `MODES[].title` in app.js, and the
|
|
41
|
+
// mirror is deliberate rather than lazy: `titleOf` used to read app.js's `MODE_BY_K` directly,
|
|
42
|
+
// which works in a browser (one global scope) and throws `ReferenceError` the moment the module is
|
|
43
|
+
// required in node — for `kind:'list'`, the root of four of the five destinations. Nothing was red,
|
|
44
|
+
// because nothing called it outside a browser; the header two lines below claimed the whole domain
|
|
45
|
+
// was walkable in node, and it was not. A module that reaches back into its caller for a lookup
|
|
46
|
+
// table is not pure, and the fix for that is to hold the table, not to keep reaching.
|
|
47
|
+
// The drift risk a mirror creates is paid for in the suite: MOBNAV-P6 iterates app.js's own MODES
|
|
48
|
+
// against this map, so a mode added or retitled there and forgotten here fails rather than
|
|
49
|
+
// silently renders a key where a title belongs.
|
|
50
|
+
const MODE_TITLE = {
|
|
51
|
+
product: 'initiatives', scheduled: 'scheduled tasks', workflow: 'workflows', skills: 'skills',
|
|
52
|
+
knowledge: 'knowledge', agent: 'agents', memory: 'memory',
|
|
53
|
+
};
|
|
54
|
+
// What a DETAIL screen is called. It moved here with the reducer, for the same reason.
|
|
55
|
+
// The second block is the four-panel surface's `detail.kind` vocabulary (pm/app.js's DETAIL_TITLE).
|
|
56
|
+
// It is ADDED rather than substituted: the first block is what the retired surface pushes and the
|
|
57
|
+
// two overlap on every key they share, so one map titles a detail screen coming from either. A
|
|
58
|
+
// missing key here is not a crash — `titleOf` falls back to 'detail' — it is a phone header that
|
|
59
|
+
// says nothing, which is why the set is written down rather than left to the fallback.
|
|
60
|
+
const DETAIL_TITLE = {
|
|
61
|
+
initiative: 'initiative', repo: 'repository', flow: 'workflow', sched: 'scheduled task',
|
|
62
|
+
skill: 'skill', know: 'knowledge', agent: 'agent', mem: 'memory',
|
|
63
|
+
ingest: 'ingest proposal', profiles: 'profiles', connections: 'connections',
|
|
64
|
+
init: 'initiative', prof: 'claude profile', intg: 'connection',
|
|
65
|
+
addprod: 'add repository', new: 'file new',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const MOBILE_DESTS = ['board', 'scheduled', 'agents', 'memory', 'more'];
|
|
69
|
+
// Every MODES key has an entry. The suite ITERATES `MODES` against this map rather than a literal
|
|
70
|
+
// list, so adding a mode without giving it a phone path fails the suite instead of going silently
|
|
71
|
+
// unreachable — which is the failure P2 exists to catch.
|
|
72
|
+
const MOBILE_DEST = { product: 'board', scheduled: 'scheduled', agent: 'agents', memory: 'memory',
|
|
73
|
+
workflow: 'more', skills: 'more', knowledge: 'more' };
|
|
74
|
+
const MOBILE_ROOT = {
|
|
75
|
+
board: { kind: 'list', mode: 'product' },
|
|
76
|
+
scheduled: { kind: 'list', mode: 'scheduled' },
|
|
77
|
+
agents: { kind: 'list', mode: 'agent' },
|
|
78
|
+
memory: { kind: 'list', mode: 'memory' },
|
|
79
|
+
more: { kind: 'more', mode: null },
|
|
80
|
+
};
|
|
81
|
+
// kind → column. There is no second input, and no screen may carry a column its kind does not give.
|
|
82
|
+
const MOBILE_COLUMN = { list: 'side', more: 'side', detail: 'rail', terminal: 'term', doc: 'term' };
|
|
83
|
+
function mobColumnOf(kind) { return MOBILE_COLUMN[kind] || 'side'; }
|
|
84
|
+
|
|
85
|
+
/* kind → PANEL INDEX, for the four-panel surface (public/pm/).
|
|
86
|
+
*
|
|
87
|
+
* WHY THIS EXISTS ALONGSIDE `column`, RATHER THAN REPLACING IT. `column` names one of the three
|
|
88
|
+
* columns of the retired `public/app.js` layout, and MOBNAV-P1 pins it to exactly those three
|
|
89
|
+
* values. The surface that ships now has FOUR panels — repositories · mode list · detail · tabs —
|
|
90
|
+
* and two of the old `side` screens belong to DIFFERENT panels there: a `list` screen is panel 2
|
|
91
|
+
* (the mode list) while a `more` screen is panel 1, which is the structure/settings panel and is
|
|
92
|
+
* therefore the phone's "more" destination in the four-panel design.
|
|
93
|
+
*
|
|
94
|
+
* So this is an ADDITIVE widening, not an edit: `columnOf` is untouched and still describes the
|
|
95
|
+
* three-column layout truthfully for the surface that owns it, and `panelOf` describes the
|
|
96
|
+
* four-panel one. Both are pure functions of `kind` alone, so the exclusivity guarantee — one
|
|
97
|
+
* visible surface per screen — is the same property stated against a different layout. Putting it
|
|
98
|
+
* HERE rather than in pm/mobile.js keeps a single node-walkable source of truth for "which surface
|
|
99
|
+
* does this screen show", which is the whole reason the reducer was pulled out of app.js.
|
|
100
|
+
*/
|
|
101
|
+
const MOBILE_PANEL = { more: 0, list: 1, detail: 2, terminal: 3, doc: 3 };
|
|
102
|
+
function mobPanelOf(kind) {
|
|
103
|
+
return Object.prototype.hasOwnProperty.call(MOBILE_PANEL, kind) ? MOBILE_PANEL[kind] : 1;
|
|
104
|
+
}
|
|
105
|
+
function mobDestForMode(k) { return MOBILE_DEST[k] || null; }
|
|
106
|
+
// Complete a partial screen. The column is derived, never passed in, so a caller cannot put a
|
|
107
|
+
// detail on the terminal column by writing it down.
|
|
108
|
+
function mobScreen(s) {
|
|
109
|
+
const kind = (s && s.kind) || 'list';
|
|
110
|
+
return { dest: (s && s.dest) || 'board', kind, mode: (s && s.mode) || null,
|
|
111
|
+
id: (s && s.id) || null, dk: (s && s.dk) || null, title: (s && s.title) || null,
|
|
112
|
+
column: mobColumnOf(kind) };
|
|
113
|
+
}
|
|
114
|
+
function mobRootOf(dest) {
|
|
115
|
+
const r = MOBILE_ROOT[dest] || MOBILE_ROOT.board;
|
|
116
|
+
return mobScreen({ dest: MOBILE_ROOT[dest] ? dest : 'board', kind: r.kind, mode: r.mode });
|
|
117
|
+
}
|
|
118
|
+
/* The title map. `list` and `more` derive; `detail` reads DETAIL_TITLE (declared far below — this
|
|
119
|
+
* is only ever called at runtime, long after that const has evaluated); `terminal` and `doc` carry
|
|
120
|
+
* a title because a session's name and a document's name are not derivable from a screen. */
|
|
121
|
+
function mobTitleOf(s) {
|
|
122
|
+
if (!s) return '';
|
|
123
|
+
if (s.kind === 'list') return MODE_TITLE[s.mode] || String(s.mode || '');
|
|
124
|
+
if (s.kind === 'more') return 'more';
|
|
125
|
+
if (s.kind === 'detail') return DETAIL_TITLE[s.dk] || 'detail';
|
|
126
|
+
return s.title || String(s.id || '');
|
|
127
|
+
}
|
|
128
|
+
function mobNavInit(dest) { return { dest: MOBILE_ROOT[dest] ? dest : 'board', stack: [] }; }
|
|
129
|
+
function mobPush(nav, screen) {
|
|
130
|
+
const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
|
|
131
|
+
return { dest: n.dest, stack: n.stack.concat([mobScreen(Object.assign({ dest: n.dest }, screen))]) };
|
|
132
|
+
}
|
|
133
|
+
// Removes EXACTLY one. Back from a destination root is a no-op — it never exits the app and never
|
|
134
|
+
// unwinds into another destination, which is the second half of P3.
|
|
135
|
+
function mobPop(nav) {
|
|
136
|
+
const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
|
|
137
|
+
return n.stack.length ? { dest: n.dest, stack: n.stack.slice(0, -1) } : { dest: n.dest, stack: [] };
|
|
138
|
+
}
|
|
139
|
+
// Choosing a destination RESETS it to its root with an empty stack — the prototype's {tab, stack:[]}.
|
|
140
|
+
function mobSwitchDest(nav, dest) { return mobNavInit(dest); }
|
|
141
|
+
function mobCurrentOf(nav) {
|
|
142
|
+
const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
|
|
143
|
+
return n.stack.length ? n.stack[n.stack.length - 1] : mobRootOf(n.dest);
|
|
144
|
+
}
|
|
145
|
+
/* R11 — the legacy key is read ONCE, for migration, and never written again. Every value the old
|
|
146
|
+
* three-way switcher could hold, plus anything unrecognised and nothing at all, lands on `board`:
|
|
147
|
+
* the destination root is the only screen that is safe to draw without re-reading anything. */
|
|
148
|
+
function mobMigrateView(v) { return MOBILE_ROOT[v] ? v : 'board'; }
|
|
149
|
+
|
|
150
|
+
return { push: mobPush, pop: mobPop, switchDest: mobSwitchDest, init: mobNavInit,
|
|
151
|
+
current: mobCurrentOf, titleOf: mobTitleOf, columnOf: mobColumnOf, panelOf: mobPanelOf,
|
|
152
|
+
destForMode: mobDestForMode,
|
|
153
|
+
MODE_TITLE,
|
|
154
|
+
migrateView: mobMigrateView, screen: mobScreen, root: mobRootOf,
|
|
155
|
+
DESTS: MOBILE_DESTS, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT,
|
|
156
|
+
PANEL_OF_KIND: MOBILE_PANEL, DETAIL_TITLE };
|
|
157
|
+
});
|
|
@@ -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 };
|