@sbains2/lifeos 0.2.0 → 0.2.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/bin/lifeos.js +5 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +60 -6
- package/src/index.js +1 -1
- package/src/remedy.js +288 -0
package/bin/lifeos.js
CHANGED
|
@@ -24,6 +24,10 @@ Wizard options:
|
|
|
24
24
|
defaults everywhere. Target install time: < 60 seconds.
|
|
25
25
|
|
|
26
26
|
Doctor options:
|
|
27
|
+
--fix Apply deterministic fixes (mkdir folders, copy missing
|
|
28
|
+
council files, generate writing_style stub, generate
|
|
29
|
+
.env.example with help URLs). Never touches secrets or
|
|
30
|
+
shell rc. Re-runs doctor after to show before/after.
|
|
27
31
|
--json Emit machine-readable JSON (for CI / scripting)
|
|
28
32
|
--quiet Suppress ✓ lines, only show warnings and errors
|
|
29
33
|
--strict Exit code 1 even on warnings (default: warnings are 0)
|
|
@@ -53,6 +57,7 @@ async function main() {
|
|
|
53
57
|
json: flags.has('--json'),
|
|
54
58
|
quiet: flags.has('--quiet'),
|
|
55
59
|
strict: flags.has('--strict'),
|
|
60
|
+
fix: flags.has('--fix'),
|
|
56
61
|
});
|
|
57
62
|
process.exit(exitCode);
|
|
58
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sbains2/lifeos",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Scaffold a personal LifeOS — life-quadrant folders, a council of AI agent voices, and curated MCP wiring. Generates a working lifeos.config.json + .claude/agents/council/ in 2-4 minutes (or <60s with --minimal). BETA — early seed release.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/doctor.js
CHANGED
|
@@ -11,25 +11,79 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { runDoctor } from '../doctor.js';
|
|
14
|
+
import { applyDoctorFixes } from '../remedy.js';
|
|
14
15
|
import { c } from '../utils/colors.js';
|
|
15
16
|
|
|
16
17
|
export async function runDoctorCommand(targetDir, options = {}) {
|
|
17
|
-
const { json = false, quiet = false, strict = false } = options;
|
|
18
|
+
const { json = false, quiet = false, strict = false, fix = false } = options;
|
|
18
19
|
|
|
19
20
|
const result = runDoctor(targetDir);
|
|
20
21
|
|
|
21
|
-
if (json) {
|
|
22
|
+
if (json && !fix) {
|
|
22
23
|
console.log(JSON.stringify(result, null, 2));
|
|
23
|
-
|
|
24
|
+
if (result.summary.error > 0) return 1;
|
|
25
|
+
if (strict && result.summary.warn > 0) return 1;
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!fix) {
|
|
24
30
|
printHumanReport(result, { quiet });
|
|
31
|
+
if (result.summary.error > 0) return 1;
|
|
32
|
+
if (strict && result.summary.warn > 0) return 1;
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --fix mode: print initial state, apply remedies, re-run doctor, print final state
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log(c.bold('lifeos doctor --fix') + c.dim(' — initial state'));
|
|
39
|
+
console.log('');
|
|
40
|
+
printSummaryLine(result.summary);
|
|
41
|
+
|
|
42
|
+
const remedy = applyDoctorFixes(targetDir);
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log(c.bold('Applying deterministic fixes...'));
|
|
45
|
+
if (remedy.applied.length === 0) {
|
|
46
|
+
console.log(c.dim(` ${c.dot} nothing to auto-fix (issues remaining need user input — see suggestions below)`));
|
|
47
|
+
} else {
|
|
48
|
+
for (const fix of remedy.applied) {
|
|
49
|
+
console.log(` ${c.check} ${fix.id}: ${fix.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (remedy.skipped.length > 0) {
|
|
53
|
+
console.log(c.dim(` Skipped (need manual action):`));
|
|
54
|
+
for (const s of remedy.skipped) {
|
|
55
|
+
console.log(c.dim(` ${c.dot} ${s.id}: ${s.reason}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Re-run doctor to show new state
|
|
60
|
+
console.log('');
|
|
61
|
+
console.log(c.bold('Re-running doctor...'));
|
|
62
|
+
console.log('');
|
|
63
|
+
const after = runDoctor(targetDir);
|
|
64
|
+
printHumanReport(after, { quiet });
|
|
65
|
+
|
|
66
|
+
if (json) {
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log(c.dim('--json + --fix combined: emitting after-state JSON'));
|
|
69
|
+
console.log(JSON.stringify(after, null, 2));
|
|
25
70
|
}
|
|
26
71
|
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
if (strict && result.summary.warn > 0) return 1;
|
|
72
|
+
if (after.summary.error > 0) return 1;
|
|
73
|
+
if (strict && after.summary.warn > 0) return 1;
|
|
30
74
|
return 0;
|
|
31
75
|
}
|
|
32
76
|
|
|
77
|
+
function printSummaryLine(summary) {
|
|
78
|
+
const { ok, warn, error } = summary;
|
|
79
|
+
const parts = [
|
|
80
|
+
`${c.ok(`${ok} ok`)}`,
|
|
81
|
+
warn > 0 ? c.warn(`${warn} warning${warn === 1 ? '' : 's'}`) : null,
|
|
82
|
+
error > 0 ? c.err(`${error} error${error === 1 ? '' : 's'}`) : null,
|
|
83
|
+
].filter(Boolean);
|
|
84
|
+
console.log(' ' + parts.join(', '));
|
|
85
|
+
}
|
|
86
|
+
|
|
33
87
|
function printHumanReport(result, { quiet }) {
|
|
34
88
|
console.log('');
|
|
35
89
|
console.log(c.bold('lifeos doctor') + c.dim(' — checking your scaffold'));
|
package/src/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export async function run(targetDir, options = {}) {
|
|
|
26
26
|
const { minimal = false } = options;
|
|
27
27
|
|
|
28
28
|
console.log('');
|
|
29
|
-
console.log(c.bold('lifeos') + c.dim(' v0.2.
|
|
29
|
+
console.log(c.bold('lifeos') + c.dim(' v0.2.1 — scaffold a personal LifeOS'));
|
|
30
30
|
console.log(c.dim(`Target directory: ${targetDir}`));
|
|
31
31
|
if (minimal) {
|
|
32
32
|
console.log(c.dim('Mode: --minimal (speed-optimized; using template defaults for quadrants, council, MCPs)'));
|
package/src/remedy.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lifeos doctor --fix` — deterministic remediation for an unhealthy scaffold.
|
|
3
|
+
*
|
|
4
|
+
* Trust posture:
|
|
5
|
+
* - NEVER touches user secrets (tokens, OAuth credentials)
|
|
6
|
+
* - NEVER edits the user's shell rc (~/.zshrc, ~/.bashrc)
|
|
7
|
+
* - NEVER overwrites existing files without backup
|
|
8
|
+
* - ONLY applies fixes for checks where the right action is unambiguous and
|
|
9
|
+
* doesn't require user input
|
|
10
|
+
*
|
|
11
|
+
* What it can fix automatically:
|
|
12
|
+
* - Missing quadrant folders → mkdir -p (with placeholder README)
|
|
13
|
+
* - Missing council files → copy from bundled templates
|
|
14
|
+
* - Missing writing_style.md (when path is set in config) → create stub
|
|
15
|
+
* - Missing .env.example for required auth env vars → generate with help-URL
|
|
16
|
+
* comments per env var. User sources it as .env.local after filling in
|
|
17
|
+
* values.
|
|
18
|
+
*
|
|
19
|
+
* What it CANNOT fix automatically (must be user-driven):
|
|
20
|
+
* - Missing env var values (we'd need the secret)
|
|
21
|
+
* - Schema version mismatch (needs `lifeos migrate` — not yet implemented)
|
|
22
|
+
* - Empty brain index (needs `lifeos brain` — not yet implemented)
|
|
23
|
+
* - Missing MCP runtimes (npx / docker / uvx — user must install)
|
|
24
|
+
*
|
|
25
|
+
* Each fix returns { id, applied: boolean, action: string, message: string }.
|
|
26
|
+
* Caller decides how to render.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { loadRegistry } from './templates-loader.js';
|
|
33
|
+
|
|
34
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const TEMPLATES_COUNCIL = join(__dirname, '..', 'templates', 'council');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Apply all deterministic fixes to a scaffold. Reads the same lifeos.config.json
|
|
39
|
+
* doctor reads. Returns { applied: [], skipped: [], summary }.
|
|
40
|
+
*/
|
|
41
|
+
export function applyDoctorFixes(targetDir, options = {}) {
|
|
42
|
+
const { allowWritingStyleStub = true, allowEnvExample = true } = options;
|
|
43
|
+
|
|
44
|
+
const configPath = join(targetDir, 'lifeos.config.json');
|
|
45
|
+
if (!existsSync(configPath)) {
|
|
46
|
+
return {
|
|
47
|
+
applied: [],
|
|
48
|
+
skipped: [{ id: 'config', reason: 'lifeos.config.json not found — nothing to remediate' }],
|
|
49
|
+
summary: { applied: 0, skipped: 1 },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
let config;
|
|
53
|
+
try {
|
|
54
|
+
config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
applied: [],
|
|
58
|
+
skipped: [{ id: 'config', reason: `JSON parse failed: ${err.message} — fix manually first` }],
|
|
59
|
+
summary: { applied: 0, skipped: 1 },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const applied = [];
|
|
64
|
+
const skipped = [];
|
|
65
|
+
|
|
66
|
+
// Fix 1: missing quadrant folders
|
|
67
|
+
for (const q of config.quadrants ?? []) {
|
|
68
|
+
if (!q.active) continue;
|
|
69
|
+
const fullPath = isAbsolute(q.path) ? q.path : join(targetDir, q.path);
|
|
70
|
+
if (existsSync(fullPath)) continue;
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(fullPath, { recursive: true });
|
|
73
|
+
const readme = join(fullPath, 'README.md');
|
|
74
|
+
if (!existsSync(readme)) {
|
|
75
|
+
writeFileSync(readme, quadrantReadme(q), 'utf8');
|
|
76
|
+
}
|
|
77
|
+
applied.push({
|
|
78
|
+
id: `quadrant_folder:${q.id}`,
|
|
79
|
+
action: `mkdir`,
|
|
80
|
+
message: `Created ${q.path}/ + placeholder README`,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
skipped.push({ id: `quadrant_folder:${q.id}`, reason: `mkdir failed: ${err.message}` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Fix 2: missing council files
|
|
88
|
+
const councilDir = join(targetDir, '.claude', 'agents', 'council');
|
|
89
|
+
for (const member of config.council ?? []) {
|
|
90
|
+
if (!member.system_prompt_path) continue;
|
|
91
|
+
const filename = member.system_prompt_path.split('/').pop();
|
|
92
|
+
const installedPath = join(councilDir, filename);
|
|
93
|
+
if (existsSync(installedPath)) continue;
|
|
94
|
+
const templatePath = join(TEMPLATES_COUNCIL, filename);
|
|
95
|
+
if (!existsSync(templatePath)) {
|
|
96
|
+
skipped.push({
|
|
97
|
+
id: `council_file:${member.id}`,
|
|
98
|
+
reason: `bundled template missing for ${filename} — file may be custom; not auto-fixable`,
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(councilDir, { recursive: true });
|
|
104
|
+
copyFileSync(templatePath, installedPath);
|
|
105
|
+
applied.push({
|
|
106
|
+
id: `council_file:${member.id}`,
|
|
107
|
+
action: 'copy',
|
|
108
|
+
message: `Copied ${filename} from bundled templates`,
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
skipped.push({ id: `council_file:${member.id}`, reason: `copy failed: ${err.message}` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fix 3: writing_style.md stub if path is set + file missing
|
|
116
|
+
if (allowWritingStyleStub && config.writing_style_path) {
|
|
117
|
+
const stylePath = isAbsolute(config.writing_style_path)
|
|
118
|
+
? config.writing_style_path
|
|
119
|
+
: join(targetDir, config.writing_style_path);
|
|
120
|
+
if (!existsSync(stylePath)) {
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(dirname(stylePath), { recursive: true });
|
|
123
|
+
writeFileSync(stylePath, writingStyleStub(), 'utf8');
|
|
124
|
+
applied.push({
|
|
125
|
+
id: 'writing_style',
|
|
126
|
+
action: 'stub',
|
|
127
|
+
message: `Wrote stub at ${config.writing_style_path} — replace with a real sample of your writing`,
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
skipped.push({ id: 'writing_style', reason: `write failed: ${err.message}` });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fix 4: .env.example for required env vars
|
|
136
|
+
if (allowEnvExample) {
|
|
137
|
+
let registry = null;
|
|
138
|
+
try {
|
|
139
|
+
registry = loadRegistry();
|
|
140
|
+
} catch {
|
|
141
|
+
/* skip env example if registry unavailable */
|
|
142
|
+
}
|
|
143
|
+
if (registry) {
|
|
144
|
+
const envEntries = collectEnvEntries(config, registry);
|
|
145
|
+
if (envEntries.length > 0) {
|
|
146
|
+
const envExamplePath = join(targetDir, '.env.example');
|
|
147
|
+
const wasExisting = existsSync(envExamplePath);
|
|
148
|
+
try {
|
|
149
|
+
// Don't overwrite if user already customized — just notify
|
|
150
|
+
if (wasExisting) {
|
|
151
|
+
skipped.push({
|
|
152
|
+
id: 'env_example',
|
|
153
|
+
reason: `.env.example already exists — not overwriting (review and merge manually if needed)`,
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
writeFileSync(envExamplePath, envExampleContent(envEntries), 'utf8');
|
|
157
|
+
applied.push({
|
|
158
|
+
id: 'env_example',
|
|
159
|
+
action: 'generate',
|
|
160
|
+
message: `Wrote .env.example with ${envEntries.length} env var stub(s) + help URLs. Copy to .env.local, fill in values, then \`source .env.local\` before launching Claude.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
skipped.push({ id: 'env_example', reason: `write failed: ${err.message}` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
applied,
|
|
172
|
+
skipped,
|
|
173
|
+
summary: { applied: applied.length, skipped: skipped.length },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Collect env var entries needed by wired MCPs.
|
|
179
|
+
* Returns [{ env_var, mcp_id, mcp_name, help_url, scope_hint }].
|
|
180
|
+
*/
|
|
181
|
+
function collectEnvEntries(config, registry) {
|
|
182
|
+
const byId = new Map(registry.servers.map((s) => [s.id, s]));
|
|
183
|
+
const entries = [];
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
for (const m of config.mcp_servers ?? []) {
|
|
186
|
+
const entry = byId.get(m.id);
|
|
187
|
+
if (!entry?.auth?.env_var) continue;
|
|
188
|
+
if (seen.has(entry.auth.env_var)) continue;
|
|
189
|
+
seen.add(entry.auth.env_var);
|
|
190
|
+
entries.push({
|
|
191
|
+
env_var: entry.auth.env_var,
|
|
192
|
+
mcp_id: entry.id,
|
|
193
|
+
mcp_name: entry.name,
|
|
194
|
+
help_url: entry.auth.help_url,
|
|
195
|
+
scope_hint: entry.auth.scope_hint,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return entries;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function envExampleContent(entries) {
|
|
202
|
+
const lines = [
|
|
203
|
+
'# LifeOS — wired MCP environment variables',
|
|
204
|
+
'#',
|
|
205
|
+
'# Generated by `lifeos doctor --fix`. To use:',
|
|
206
|
+
'# 1. Copy this file to .env.local in the same directory',
|
|
207
|
+
'# 2. Fill in each value from the help URLs below',
|
|
208
|
+
'# 3. Source it before launching Claude Code:',
|
|
209
|
+
'# source .env.local',
|
|
210
|
+
'# or add a permanent `set -a; source .env.local; set +a` to your shell rc.',
|
|
211
|
+
'#',
|
|
212
|
+
'# Trust note: .env.local should be gitignored (LifeOS scaffold .gitignore covers this).',
|
|
213
|
+
'# LifeOS itself never reads any value in this file — it\'s yours, in your shell.',
|
|
214
|
+
'',
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
for (const e of entries) {
|
|
218
|
+
lines.push(`# ${e.mcp_name}`);
|
|
219
|
+
if (e.help_url) lines.push(`# Get one: ${e.help_url}`);
|
|
220
|
+
if (e.scope_hint) {
|
|
221
|
+
const wrapped = wrapText(`# Scope: ${e.scope_hint}`, 100);
|
|
222
|
+
lines.push(...wrapped);
|
|
223
|
+
}
|
|
224
|
+
lines.push(`export ${e.env_var}=`);
|
|
225
|
+
lines.push('');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function wrapText(text, maxWidth) {
|
|
232
|
+
const lines = [];
|
|
233
|
+
const prefix = text.startsWith('#') ? '# ' : '';
|
|
234
|
+
const content = text.startsWith('#') ? text.slice(2) : text;
|
|
235
|
+
const words = content.split(/\s+/);
|
|
236
|
+
let current = '';
|
|
237
|
+
for (const word of words) {
|
|
238
|
+
if (current.length + word.length + 1 > maxWidth - prefix.length) {
|
|
239
|
+
if (current) lines.push(prefix + current);
|
|
240
|
+
current = word;
|
|
241
|
+
} else {
|
|
242
|
+
current = current ? `${current} ${word}` : word;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (current) lines.push(prefix + current);
|
|
246
|
+
return lines;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function quadrantReadme(q) {
|
|
250
|
+
return `# ${q.name}
|
|
251
|
+
|
|
252
|
+
${q.description ?? ''}
|
|
253
|
+
|
|
254
|
+
This folder is the home of your **${q.id}** quadrant. The LifeOS council members assigned to it will read files here as context when convened.
|
|
255
|
+
|
|
256
|
+
## Council members assigned
|
|
257
|
+
|
|
258
|
+
${(q.council_member_ids ?? []).map((id) => `- \`${id}\``).join('\n') || '_none_'}
|
|
259
|
+
|
|
260
|
+
## MCP servers wired
|
|
261
|
+
|
|
262
|
+
${(q.mcp_server_ids ?? []).map((id) => `- \`${id}\``).join('\n') || '_none_'}
|
|
263
|
+
|
|
264
|
+
## Priority
|
|
265
|
+
|
|
266
|
+
\`${q.priority}\` — change in \`lifeos.config.json\` to adjust how much airtime this quadrant gets in cross-quadrant convenings.
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function writingStyleStub() {
|
|
271
|
+
return `# Writing style
|
|
272
|
+
|
|
273
|
+
> This file anchors your voice for council members that produce written output (Writing Critic, Editor, etc.).
|
|
274
|
+
> Replace this placeholder with a real sample of your writing — an email, an essay paragraph, a Slack message you're proud of.
|
|
275
|
+
> The council reads this verbatim and tries to match the register. Be specific.
|
|
276
|
+
|
|
277
|
+
## Style notes
|
|
278
|
+
|
|
279
|
+
- _Tone:_ [e.g., formal-analytical, conversational, direct, hedged]
|
|
280
|
+
- _Sentence length preference:_ [e.g., short and punchy / long and layered]
|
|
281
|
+
- _Vocabulary preferences:_ [e.g., academic, plain-spoken, jargon-heavy]
|
|
282
|
+
- _Avoid:_ [e.g., overly casual contractions, filler phrases, marketing-speak]
|
|
283
|
+
|
|
284
|
+
## Sample (replace this entire section with your own)
|
|
285
|
+
|
|
286
|
+
[Paste 200-500 words of your own writing here. Anything you wrote and stand behind.]
|
|
287
|
+
`;
|
|
288
|
+
}
|