frontier-os-app-builder 1.0.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.
Files changed (55) hide show
  1. package/README.md +92 -0
  2. package/agents/fos-executor.md +460 -0
  3. package/agents/fos-plan-checker.md +386 -0
  4. package/agents/fos-planner.md +416 -0
  5. package/agents/fos-researcher.md +358 -0
  6. package/agents/fos-verifier.md +491 -0
  7. package/bin/fos-tools.cjs +794 -0
  8. package/bin/install.js +234 -0
  9. package/commands/fos/add-feature.md +29 -0
  10. package/commands/fos/discuss.md +31 -0
  11. package/commands/fos/execute.md +35 -0
  12. package/commands/fos/new-app.md +39 -0
  13. package/commands/fos/new-milestone.md +28 -0
  14. package/commands/fos/next.md +29 -0
  15. package/commands/fos/plan.md +37 -0
  16. package/commands/fos/ship.md +29 -0
  17. package/commands/fos/status.md +22 -0
  18. package/package.json +30 -0
  19. package/references/app-patterns.md +501 -0
  20. package/references/deployment.md +395 -0
  21. package/references/module-inference.md +349 -0
  22. package/references/sdk-surface.md +1622 -0
  23. package/references/verification-rules.md +404 -0
  24. package/templates/app/gitignore +25 -0
  25. package/templates/app/index.css +111 -0
  26. package/templates/app/index.html +19 -0
  27. package/templates/app/layout.tsx +45 -0
  28. package/templates/app/main-router.tsx +17 -0
  29. package/templates/app/main-simple.tsx +19 -0
  30. package/templates/app/package.json +36 -0
  31. package/templates/app/postcss.config.js +5 -0
  32. package/templates/app/router.tsx +22 -0
  33. package/templates/app/sdk-context.tsx +33 -0
  34. package/templates/app/test-setup.ts +19 -0
  35. package/templates/app/tsconfig.json +22 -0
  36. package/templates/app/vercel.json +127 -0
  37. package/templates/app/vite.config.ts +15 -0
  38. package/templates/state/context.md +248 -0
  39. package/templates/state/manifest.json +11 -0
  40. package/templates/state/plan.md +187 -0
  41. package/templates/state/project.md +118 -0
  42. package/templates/state/requirements.md +133 -0
  43. package/templates/state/roadmap.md +129 -0
  44. package/templates/state/state.md +131 -0
  45. package/templates/state/summary.md +273 -0
  46. package/workflows/add-feature.md +234 -0
  47. package/workflows/discuss.md +310 -0
  48. package/workflows/execute-plan.md +222 -0
  49. package/workflows/execute.md +338 -0
  50. package/workflows/new-app.md +331 -0
  51. package/workflows/new-milestone.md +258 -0
  52. package/workflows/next.md +157 -0
  53. package/workflows/plan.md +310 -0
  54. package/workflows/ship.md +296 -0
  55. package/workflows/status.md +145 -0
@@ -0,0 +1,794 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execSync } = require('child_process');
7
+
8
+ // ─────────────────────────────────────────────
9
+ // FOS Tools — CLI utility for Frontier OS App Builder
10
+ // Usage: node fos-tools.cjs <command> [args] [--raw] [--pick <field>]
11
+ // ─────────────────────────────────────────────
12
+
13
+ const VERSION = '1.0.0';
14
+
15
+ // ── Helpers ──────────────────────────────────
16
+
17
+ function fosDir(cwd) {
18
+ return path.join(cwd || process.cwd(), '.frontier-app');
19
+ }
20
+
21
+ function fosExists(cwd) {
22
+ return fs.existsSync(fosDir(cwd));
23
+ }
24
+
25
+ function readFile(filePath) {
26
+ if (!fs.existsSync(filePath)) return null;
27
+ return fs.readFileSync(filePath, 'utf-8');
28
+ }
29
+
30
+ function writeFile(filePath, content) {
31
+ const dir = path.dirname(filePath);
32
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
33
+ fs.writeFileSync(filePath, content, 'utf-8');
34
+ }
35
+
36
+ function output(data, flags) {
37
+ if (flags.pick && typeof data === 'object') {
38
+ const val = data[flags.pick];
39
+ process.stdout.write(typeof val === 'string' ? val : JSON.stringify(val));
40
+ } else if (flags.raw && typeof data === 'string') {
41
+ process.stdout.write(data);
42
+ } else {
43
+ const json = JSON.stringify(data, null, 2);
44
+ // If output is too large for stdout, write to temp file
45
+ if (json.length > 50000) {
46
+ const tmp = path.join(require('os').tmpdir(), `fos-${Date.now()}.json`);
47
+ fs.writeFileSync(tmp, json);
48
+ process.stdout.write(`@file:${tmp}`);
49
+ } else {
50
+ process.stdout.write(json);
51
+ }
52
+ }
53
+ }
54
+
55
+ function error(msg) {
56
+ process.stderr.write(`Error: ${msg}\n`);
57
+ process.exit(1);
58
+ }
59
+
60
+ function generateSlug(text) {
61
+ return text
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9\s-]/g, '')
64
+ .replace(/\s+/g, '-')
65
+ .replace(/-+/g, '-')
66
+ .replace(/^-|-$/g, '')
67
+ .slice(0, 50);
68
+ }
69
+
70
+ // ── YAML Frontmatter Parsing ─────────────────
71
+
72
+ function parseFrontmatter(content) {
73
+ if (!content) return { frontmatter: {}, body: '' };
74
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
75
+ if (!match) return { frontmatter: {}, body: content };
76
+
77
+ const fm = {};
78
+ let currentKey = null;
79
+ let currentIndent = 0;
80
+
81
+ for (const line of match[1].split('\n')) {
82
+ const kvMatch = line.match(/^(\w[\w_-]*)\s*:\s*(.*)$/);
83
+ if (kvMatch) {
84
+ const [, key, val] = kvMatch;
85
+ const trimmed = val.trim();
86
+ if (trimmed === '' || trimmed === '|') {
87
+ fm[key] = '';
88
+ currentKey = key;
89
+ } else if (trimmed === '[]') {
90
+ fm[key] = [];
91
+ currentKey = key;
92
+ } else if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
93
+ fm[key] = trimmed.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
94
+ currentKey = null;
95
+ } else if (trimmed === 'true') {
96
+ fm[key] = true; currentKey = null;
97
+ } else if (trimmed === 'false') {
98
+ fm[key] = false; currentKey = null;
99
+ } else if (/^\d+$/.test(trimmed)) {
100
+ fm[key] = parseInt(trimmed, 10); currentKey = null;
101
+ } else {
102
+ fm[key] = trimmed.replace(/^["']|["']$/g, '');
103
+ currentKey = null;
104
+ }
105
+ } else if (currentKey && line.match(/^\s+-\s+/)) {
106
+ const item = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
107
+ if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
108
+ fm[currentKey].push(item);
109
+ } else if (currentKey && line.match(/^\s+\S/)) {
110
+ fm[currentKey] += (fm[currentKey] ? '\n' : '') + line.trimStart();
111
+ }
112
+ }
113
+
114
+ return { frontmatter: fm, body: match[2] };
115
+ }
116
+
117
+ function serializeFrontmatter(fm, body) {
118
+ const lines = ['---'];
119
+ for (const [key, val] of Object.entries(fm)) {
120
+ if (Array.isArray(val)) {
121
+ if (val.length === 0) {
122
+ lines.push(`${key}: []`);
123
+ } else {
124
+ lines.push(`${key}:`);
125
+ val.forEach(v => lines.push(` - ${v}`));
126
+ }
127
+ } else if (typeof val === 'boolean') {
128
+ lines.push(`${key}: ${val}`);
129
+ } else if (typeof val === 'number') {
130
+ lines.push(`${key}: ${val}`);
131
+ } else {
132
+ lines.push(`${key}: ${val}`);
133
+ }
134
+ }
135
+ lines.push('---');
136
+ return lines.join('\n') + '\n' + (body || '');
137
+ }
138
+
139
+ // ── Manifest ─────────────────────────────────
140
+
141
+ function loadManifest(cwd) {
142
+ const p = path.join(fosDir(cwd), 'manifest.json');
143
+ const raw = readFile(p);
144
+ if (!raw) return null;
145
+ try { return JSON.parse(raw); } catch { return null; }
146
+ }
147
+
148
+ function saveManifest(cwd, manifest) {
149
+ writeFile(path.join(fosDir(cwd), 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
150
+ }
151
+
152
+ // ── State ────────────────────────────────────
153
+
154
+ function loadState(cwd) {
155
+ const p = path.join(fosDir(cwd), 'STATE.md');
156
+ const raw = readFile(p);
157
+ if (!raw) return null;
158
+ return parseFrontmatter(raw);
159
+ }
160
+
161
+ function saveState(cwd, fm, body) {
162
+ writeFile(path.join(fosDir(cwd), 'STATE.md'), serializeFrontmatter(fm, body));
163
+ }
164
+
165
+ function cmdStateLoad(cwd, flags) {
166
+ const state = loadState(cwd);
167
+ if (!state) error('.frontier-app/STATE.md not found');
168
+ output(flags.raw ? state.body : state, flags);
169
+ }
170
+
171
+ function cmdStateJson(cwd, flags) {
172
+ const state = loadState(cwd);
173
+ if (!state) error('.frontier-app/STATE.md not found');
174
+ output(state.frontmatter, flags);
175
+ }
176
+
177
+ function cmdStateUpdate(cwd, field, value, flags) {
178
+ const state = loadState(cwd);
179
+ if (!state) error('.frontier-app/STATE.md not found');
180
+ // Parse value
181
+ let parsed = value;
182
+ if (value === 'true') parsed = true;
183
+ else if (value === 'false') parsed = false;
184
+ else if (/^\d+$/.test(value)) parsed = parseInt(value, 10);
185
+
186
+ state.frontmatter[field] = parsed;
187
+ saveState(cwd, state.frontmatter, state.body);
188
+ output({ updated: field, value: parsed }, flags);
189
+ }
190
+
191
+ function cmdStateGet(cwd, field, flags) {
192
+ const state = loadState(cwd);
193
+ if (!state) error('.frontier-app/STATE.md not found');
194
+ const val = state.frontmatter[field];
195
+ if (val === undefined) error(`Field '${field}' not found in STATE.md`);
196
+ output(flags.raw ? String(val) : { [field]: val }, flags);
197
+ }
198
+
199
+ // ── Phase Operations ─────────────────────────
200
+
201
+ function findPhaseDir(cwd, phaseNum) {
202
+ const phasesDir = path.join(fosDir(cwd), 'phases');
203
+ if (!fs.existsSync(phasesDir)) return null;
204
+
205
+ const prefix = String(phaseNum).padStart(2, '0');
206
+ const entries = fs.readdirSync(phasesDir);
207
+ const match = entries.find(e => e.startsWith(prefix + '-'));
208
+ return match ? path.join(phasesDir, match) : null;
209
+ }
210
+
211
+ function cmdFindPhase(cwd, phaseNum, flags) {
212
+ if (!phaseNum) error('Phase number required');
213
+ const dir = findPhaseDir(cwd, phaseNum);
214
+ if (!dir) error(`Phase ${phaseNum} directory not found`);
215
+ output(flags.raw ? dir : { phase: phaseNum, directory: dir }, flags);
216
+ }
217
+
218
+ function listPlans(phaseDir) {
219
+ if (!phaseDir || !fs.existsSync(phaseDir)) return [];
220
+ return fs.readdirSync(phaseDir)
221
+ .filter(f => f.match(/^\d{2}-\d{2}-PLAN\.md$/))
222
+ .sort();
223
+ }
224
+
225
+ function listSummaries(phaseDir) {
226
+ if (!phaseDir || !fs.existsSync(phaseDir)) return [];
227
+ return fs.readdirSync(phaseDir)
228
+ .filter(f => f.match(/^\d{2}-\d{2}-SUMMARY\.md$/))
229
+ .sort();
230
+ }
231
+
232
+ // ── Scaffold ─────────────────────────────────
233
+
234
+ function cmdScaffold(templateName, varsJson, flags) {
235
+ const fosHome = process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder');
236
+ const templatePath = path.join(fosHome, 'templates', templateName);
237
+
238
+ if (!fs.existsSync(templatePath)) {
239
+ error(`Template not found: ${templateName} (looked at ${templatePath})`);
240
+ }
241
+
242
+ let content = fs.readFileSync(templatePath, 'utf-8');
243
+
244
+ // Parse vars
245
+ let vars = {};
246
+ if (varsJson) {
247
+ try { vars = JSON.parse(varsJson); } catch { error(`Invalid JSON vars: ${varsJson}`); }
248
+ }
249
+
250
+ // Substitute {{VAR}} patterns
251
+ for (const [key, val] of Object.entries(vars)) {
252
+ const pattern = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
253
+ content = content.replace(pattern, val);
254
+ }
255
+
256
+ if (flags.raw) {
257
+ process.stdout.write(content);
258
+ } else {
259
+ output({ template: templateName, vars: Object.keys(vars), content }, flags);
260
+ }
261
+ }
262
+
263
+ // ── Module Inference ─────────────────────────
264
+
265
+ const MODULE_KEYWORDS = {
266
+ Wallet: {
267
+ keywords: ['payment', 'pay', 'charge', 'pos', 'checkout', 'purchase', 'buy', 'sell',
268
+ 'transfer', 'send money', 'balance', 'funds', 'money', 'wallet', 'fnd',
269
+ 'swap', 'exchange', 'convert', 'token', 'deposit', 'on-ramp', 'fund',
270
+ 'withdraw', 'off-ramp', 'bank', 'fiat', 'subscription', 'billing', 'price',
271
+ 'cost', 'fee', 'tip', 'donate', 'donation'],
272
+ getter: 'sdk.getWallet()',
273
+ commonMethods: ['getBalance', 'getBalanceFormatted', 'transferFrontierDollar', 'payWithFrontierDollar'],
274
+ permissions: ['wallet:getBalance', 'wallet:getBalanceFormatted', 'wallet:getAddress',
275
+ 'wallet:transferFrontierDollar', 'wallet:payWithFrontierDollar']
276
+ },
277
+ User: {
278
+ keywords: ['user', 'profile', 'account', 'member', 'membership', 'auth', 'login',
279
+ 'referral', 'invite', 'refer', 'signup', 'register', 'kyc', 'verify',
280
+ 'identity', 'access control', 'gate', 'permission', 'name', 'person'],
281
+ getter: 'sdk.getUser()',
282
+ commonMethods: ['getDetails', 'getProfile', 'getVerifiedAccessControls'],
283
+ permissions: ['user:getDetails', 'user:getProfile', 'user:getVerifiedAccessControls']
284
+ },
285
+ Events: {
286
+ keywords: ['event', 'meetup', 'gathering', 'calendar', 'schedule', 'room', 'booking',
287
+ 'reserve', 'reservation', 'space', 'venue', 'location', 'conference',
288
+ 'meeting', 'coworking'],
289
+ getter: 'sdk.getEvents()',
290
+ commonMethods: ['listEvents', 'createEvent', 'listLocations', 'createRoomBooking'],
291
+ permissions: ['events:listEvents', 'events:createEvent', 'events:listLocations',
292
+ 'events:listRoomBookings', 'events:createRoomBooking']
293
+ },
294
+ Communities: {
295
+ keywords: ['community', 'group', 'team', 'club', 'internship', 'intern', 'cohort',
296
+ 'reassign', 'transfer member', 'collective', 'society'],
297
+ getter: 'sdk.getCommunities()',
298
+ commonMethods: ['listCommunities', 'getCommunity'],
299
+ permissions: ['communities:listCommunities', 'communities:getCommunity']
300
+ },
301
+ Partnerships: {
302
+ keywords: ['sponsor', 'partnership', 'partner', 'sponsorship', 'benefactor',
303
+ 'supporter', 'patron'],
304
+ getter: 'sdk.getPartnerships()',
305
+ commonMethods: ['listSponsors', 'getSponsor', 'createSponsorPass'],
306
+ permissions: ['partnerships:listSponsors', 'partnerships:getSponsor',
307
+ 'partnerships:createSponsorPass']
308
+ },
309
+ Offices: {
310
+ keywords: ['office', 'access pass', 'building', 'door', 'entry', 'visitor',
311
+ 'check-in', 'checkin', 'physical access', 'facility'],
312
+ getter: 'sdk.getOffices()',
313
+ commonMethods: ['createAccessPass', 'listAccessPasses'],
314
+ permissions: ['offices:createAccessPass', 'offices:listAccessPasses']
315
+ },
316
+ ThirdParty: {
317
+ keywords: ['developer', 'api key', 'webhook', 'app registration', 'app store',
318
+ 'third party', 'integration', 'external', 'developer portal'],
319
+ getter: 'sdk.getThirdParty()',
320
+ commonMethods: ['listDevelopers', 'createApp', 'createWebhook'],
321
+ permissions: ['thirdParty:listDevelopers', 'thirdParty:createApp']
322
+ },
323
+ Storage: {
324
+ keywords: ['storage', 'persist', 'save', 'preferences', 'settings', 'cache',
325
+ 'remember', 'state', 'draft', 'favorites', 'bookmarks'],
326
+ getter: 'sdk.getStorage()',
327
+ commonMethods: ['get', 'set', 'remove', 'clear'],
328
+ permissions: ['storage:get', 'storage:set', 'storage:remove']
329
+ },
330
+ Chain: {
331
+ keywords: ['network', 'chain', 'blockchain', 'contract', 'smart contract',
332
+ 'on-chain', 'token address', 'stablecoin'],
333
+ getter: 'sdk.getChain()',
334
+ commonMethods: ['getCurrentNetwork', 'getContractAddresses', 'getCurrentChainConfig'],
335
+ permissions: ['chain:getCurrentNetwork', 'chain:getContractAddresses']
336
+ },
337
+ Navigation: {
338
+ keywords: ['navigate', 'deep link', 'deeplink', 'open app', 'app link',
339
+ 'cross-app', 'redirect', 'launch app', 'inter-app'],
340
+ getter: 'sdk.getNavigation()',
341
+ commonMethods: ['openApp', 'close', 'onDeepLink'],
342
+ permissions: ['navigation:openApp', 'navigation:close']
343
+ }
344
+ };
345
+
346
+ // Modules that are always included
347
+ const BASE_MODULES = ['Storage', 'Chain'];
348
+ // User is included if any user-facing feature is detected
349
+ const USER_TRIGGER_MODULES = ['Wallet', 'Events', 'Communities', 'Partnerships', 'Offices'];
350
+
351
+ function cmdInferModules(description, flags) {
352
+ if (!description) error('Description required');
353
+
354
+ const desc = description.toLowerCase();
355
+ const matched = new Set(BASE_MODULES);
356
+ const matchDetails = {};
357
+
358
+ for (const [moduleName, config] of Object.entries(MODULE_KEYWORDS)) {
359
+ const hits = config.keywords.filter(kw => desc.includes(kw));
360
+ if (hits.length > 0) {
361
+ matched.add(moduleName);
362
+ matchDetails[moduleName] = {
363
+ matchedKeywords: hits,
364
+ getter: config.getter,
365
+ suggestedMethods: config.commonMethods,
366
+ permissions: config.permissions
367
+ };
368
+ }
369
+ }
370
+
371
+ // Add User if any user-facing module was matched
372
+ if (USER_TRIGGER_MODULES.some(m => matched.has(m))) {
373
+ matched.add('User');
374
+ if (!matchDetails.User) {
375
+ matchDetails.User = {
376
+ matchedKeywords: ['(implied by user-facing features)'],
377
+ getter: MODULE_KEYWORDS.User.getter,
378
+ suggestedMethods: MODULE_KEYWORDS.User.commonMethods,
379
+ permissions: MODULE_KEYWORDS.User.permissions
380
+ };
381
+ }
382
+ }
383
+
384
+ // Add base module details
385
+ for (const base of BASE_MODULES) {
386
+ if (!matchDetails[base]) {
387
+ matchDetails[base] = {
388
+ matchedKeywords: ['(always included)'],
389
+ getter: MODULE_KEYWORDS[base].getter,
390
+ suggestedMethods: MODULE_KEYWORDS[base].commonMethods,
391
+ permissions: MODULE_KEYWORDS[base].permissions
392
+ };
393
+ }
394
+ }
395
+
396
+ // Collect all permissions
397
+ const allPermissions = [];
398
+ for (const mod of matched) {
399
+ const perms = MODULE_KEYWORDS[mod]?.permissions || [];
400
+ allPermissions.push(...perms);
401
+ }
402
+
403
+ output({
404
+ description,
405
+ modules: Array.from(matched).sort(),
406
+ details: matchDetails,
407
+ permissions: allPermissions,
408
+ moduleCount: matched.size
409
+ }, flags);
410
+ }
411
+
412
+ // ── Validation ───────────────────────────────
413
+
414
+ function cmdValidateStructure(cwd, flags) {
415
+ const issues = [];
416
+ const checks = [];
417
+
418
+ const requiredFiles = [
419
+ 'src/lib/sdk-context.tsx',
420
+ 'src/views/Layout.tsx',
421
+ 'src/main.tsx',
422
+ 'src/styles/index.css',
423
+ 'vite.config.ts',
424
+ 'tsconfig.json',
425
+ 'postcss.config.js',
426
+ 'vercel.json',
427
+ 'index.html',
428
+ 'package.json'
429
+ ];
430
+
431
+ for (const file of requiredFiles) {
432
+ const exists = fs.existsSync(path.join(cwd, file));
433
+ checks.push({ file, exists });
434
+ if (!exists) issues.push(`Missing required file: ${file}`);
435
+ }
436
+
437
+ // Check vercel.json has all 5 CORS origins
438
+ const vercelPath = path.join(cwd, 'vercel.json');
439
+ if (fs.existsSync(vercelPath)) {
440
+ const vercel = readFile(vercelPath);
441
+ const origins = [
442
+ 'http://localhost:5173',
443
+ 'https://sandbox.os.frontiertower.io',
444
+ 'https://alpha.os.frontiertower.io',
445
+ 'https://beta.os.frontiertower.io',
446
+ 'https://os.frontiertower.io'
447
+ ];
448
+ for (const origin of origins) {
449
+ if (!vercel.includes(origin)) {
450
+ issues.push(`vercel.json missing CORS origin: ${origin}`);
451
+ }
452
+ }
453
+ }
454
+
455
+ // Check Layout.tsx has iframe detection
456
+ const layoutPath = path.join(cwd, 'src/views/Layout.tsx');
457
+ if (fs.existsSync(layoutPath)) {
458
+ const layout = readFile(layoutPath);
459
+ if (!layout.includes('isInFrontierApp')) {
460
+ issues.push('Layout.tsx missing isInFrontierApp() check');
461
+ }
462
+ if (!layout.includes('createStandaloneHTML') && !layout.includes('renderStandaloneMessage')) {
463
+ issues.push('Layout.tsx missing standalone fallback');
464
+ }
465
+ if (!layout.includes('SdkProvider')) {
466
+ issues.push('Layout.tsx missing SdkProvider wrapping');
467
+ }
468
+ }
469
+
470
+ // Check index.html has dark class
471
+ const htmlPath = path.join(cwd, 'index.html');
472
+ if (fs.existsSync(htmlPath)) {
473
+ const html = readFile(htmlPath);
474
+ if (!html.includes('class="dark"')) {
475
+ issues.push('index.html missing class="dark" on body');
476
+ }
477
+ }
478
+
479
+ // Check test setup exists
480
+ const testSetup = path.join(cwd, 'src/test/setup.ts');
481
+ if (!fs.existsSync(testSetup)) {
482
+ issues.push('Missing test setup: src/test/setup.ts');
483
+ }
484
+
485
+ output({
486
+ pass: issues.length === 0,
487
+ checks,
488
+ issues,
489
+ checkedFiles: requiredFiles.length
490
+ }, flags);
491
+ }
492
+
493
+ function cmdValidatePermissions(cwd, flags) {
494
+ const manifest = loadManifest(cwd);
495
+ if (!manifest) error('.frontier-app/manifest.json not found');
496
+
497
+ const issues = [];
498
+ const srcDir = path.join(cwd, 'src');
499
+
500
+ // Find all SDK method calls in source
501
+ const usedMethods = new Set();
502
+ function scanDir(dir) {
503
+ if (!fs.existsSync(dir)) return;
504
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
505
+ if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'test') {
506
+ scanDir(path.join(dir, entry.name));
507
+ } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
508
+ const content = readFile(path.join(dir, entry.name));
509
+ // Match sdk.getX().methodName() or getX().methodName() patterns
510
+ const calls = content.match(/\.(getWallet|getUser|getStorage|getChain|getEvents|getCommunities|getPartnerships|getOffices|getThirdParty)\(\)\.\w+/g);
511
+ if (calls) calls.forEach(c => usedMethods.add(c.slice(1)));
512
+ }
513
+ }
514
+ }
515
+ scanDir(srcDir);
516
+
517
+ // Map method calls to required permissions
518
+ const methodToPermission = {};
519
+ for (const [mod, config] of Object.entries(MODULE_KEYWORDS)) {
520
+ for (const perm of config.permissions) {
521
+ const parts = perm.split(':');
522
+ methodToPermission[`${config.getter.replace('sdk.', '')}.${parts[1]}`] = perm;
523
+ }
524
+ }
525
+
526
+ // Check each used method has a corresponding permission
527
+ const declaredPerms = new Set(manifest.permissions || []);
528
+ const missingPerms = [];
529
+ for (const method of usedMethods) {
530
+ // Try to find matching permission
531
+ for (const [pattern, perm] of Object.entries(methodToPermission)) {
532
+ if (method.includes(pattern.split('.')[0]) && !declaredPerms.has(perm)) {
533
+ missingPerms.push({ method, permission: perm });
534
+ }
535
+ }
536
+ }
537
+
538
+ if (missingPerms.length > 0) {
539
+ issues.push(...missingPerms.map(m => `SDK method ${m.method} used but permission ${m.permission} not in manifest`));
540
+ }
541
+
542
+ output({
543
+ pass: issues.length === 0,
544
+ declaredPermissions: manifest.permissions,
545
+ usedMethods: Array.from(usedMethods),
546
+ issues,
547
+ missingPermissions: missingPerms
548
+ }, flags);
549
+ }
550
+
551
+ // ── Init (Compound Context Loaders) ──────────
552
+
553
+ function cmdInit(workflow, phaseArg, flags) {
554
+ const cwd = process.cwd();
555
+
556
+ switch (workflow) {
557
+ case 'new-app': {
558
+ const exists = fosExists(cwd);
559
+ const hasGit = fs.existsSync(path.join(cwd, '.git'));
560
+ const templateHome = process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder');
561
+ output({
562
+ project_exists: exists,
563
+ has_git: hasGit,
564
+ cwd,
565
+ template_home: templateHome,
566
+ version: VERSION
567
+ }, flags);
568
+ break;
569
+ }
570
+
571
+ case 'discuss': {
572
+ if (!phaseArg) error('Phase number required for discuss init');
573
+ if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
574
+ const manifest = loadManifest(cwd);
575
+ const state = loadState(cwd);
576
+ const phaseDir = findPhaseDir(cwd, phaseArg);
577
+ const hasContext = phaseDir && fs.existsSync(path.join(phaseDir, `${String(phaseArg).padStart(2, '0')}-CONTEXT.md`));
578
+ const roadmap = readFile(path.join(fosDir(cwd), 'ROADMAP.md'));
579
+
580
+ output({
581
+ phase: parseInt(phaseArg),
582
+ phase_dir: phaseDir,
583
+ has_context: hasContext,
584
+ manifest,
585
+ state: state?.frontmatter || {},
586
+ roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
587
+ project_path: path.join(fosDir(cwd), 'PROJECT.md'),
588
+ version: VERSION
589
+ }, flags);
590
+ break;
591
+ }
592
+
593
+ case 'plan': {
594
+ if (!phaseArg) error('Phase number required for plan init');
595
+ if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
596
+ const manifest = loadManifest(cwd);
597
+ const state = loadState(cwd);
598
+ const phaseDir = findPhaseDir(cwd, phaseArg);
599
+ const prefix = String(phaseArg).padStart(2, '0');
600
+ const hasContext = phaseDir && fs.existsSync(path.join(phaseDir, `${prefix}-CONTEXT.md`));
601
+ const hasResearch = phaseDir && fs.existsSync(path.join(phaseDir, `${prefix}-RESEARCH.md`));
602
+ const existingPlans = phaseDir ? listPlans(phaseDir) : [];
603
+
604
+ output({
605
+ phase: parseInt(phaseArg),
606
+ phase_dir: phaseDir,
607
+ has_context: hasContext,
608
+ has_research: hasResearch,
609
+ existing_plans: existingPlans,
610
+ manifest,
611
+ state: state?.frontmatter || {},
612
+ project_path: path.join(fosDir(cwd), 'PROJECT.md'),
613
+ roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
614
+ template_home: process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder'),
615
+ version: VERSION
616
+ }, flags);
617
+ break;
618
+ }
619
+
620
+ case 'execute': {
621
+ if (!phaseArg) error('Phase number required for execute init');
622
+ if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
623
+ const manifest = loadManifest(cwd);
624
+ const state = loadState(cwd);
625
+ const phaseDir = findPhaseDir(cwd, phaseArg);
626
+ const plans = phaseDir ? listPlans(phaseDir) : [];
627
+ const summaries = phaseDir ? listSummaries(phaseDir) : [];
628
+ const completedPlanIds = summaries.map(s => s.replace('-SUMMARY.md', ''));
629
+ const incompletePlans = plans.filter(p => !completedPlanIds.includes(p.replace('-PLAN.md', '')));
630
+
631
+ output({
632
+ phase: parseInt(phaseArg),
633
+ phase_dir: phaseDir,
634
+ plans,
635
+ summaries,
636
+ incomplete_plans: incompletePlans,
637
+ all_complete: incompletePlans.length === 0,
638
+ manifest,
639
+ state: state?.frontmatter || {},
640
+ project_path: path.join(fosDir(cwd), 'PROJECT.md'),
641
+ roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
642
+ template_home: process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder'),
643
+ version: VERSION
644
+ }, flags);
645
+ break;
646
+ }
647
+
648
+ case 'ship': {
649
+ if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
650
+ const manifest = loadManifest(cwd);
651
+ const state = loadState(cwd);
652
+ const roadmap = readFile(path.join(fosDir(cwd), 'ROADMAP.md'));
653
+
654
+ // Check if all phases have verifications
655
+ const phasesDir = path.join(fosDir(cwd), 'phases');
656
+ let allVerified = true;
657
+ if (fs.existsSync(phasesDir)) {
658
+ for (const entry of fs.readdirSync(phasesDir)) {
659
+ const phaseNum = entry.split('-')[0];
660
+ const verifPath = path.join(phasesDir, entry, `${phaseNum}-VERIFICATION.md`);
661
+ if (!fs.existsSync(verifPath)) {
662
+ allVerified = false;
663
+ break;
664
+ }
665
+ }
666
+ }
667
+
668
+ output({
669
+ manifest,
670
+ state: state?.frontmatter || {},
671
+ all_verified: allVerified,
672
+ project_path: path.join(fosDir(cwd), 'PROJECT.md'),
673
+ roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
674
+ version: VERSION
675
+ }, flags);
676
+ break;
677
+ }
678
+
679
+ default:
680
+ error(`Unknown init workflow: ${workflow}. Valid: new-app, discuss, plan, execute, ship`);
681
+ }
682
+ }
683
+
684
+ // ── Commit Helper ────────────────────────────
685
+
686
+ function cmdCommit(message, files, flags) {
687
+ if (!message) error('Commit message required');
688
+ try {
689
+ if (files && files.length > 0) {
690
+ execSync(`git add ${files.map(f => `"${f}"`).join(' ')}`, { stdio: 'pipe' });
691
+ }
692
+ execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
693
+ const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
694
+ output({ committed: true, hash, message }, flags);
695
+ } catch (e) {
696
+ error(`Git commit failed: ${e.message}`);
697
+ }
698
+ }
699
+
700
+ // ── Main Router ──────────────────────────────
701
+
702
+ function main() {
703
+ const args = process.argv.slice(2);
704
+ const flags = { raw: false, pick: null };
705
+
706
+ // Extract flags
707
+ const cleanArgs = [];
708
+ for (let i = 0; i < args.length; i++) {
709
+ if (args[i] === '--raw') { flags.raw = true; }
710
+ else if (args[i] === '--pick' && args[i + 1]) { flags.pick = args[++i]; }
711
+ else { cleanArgs.push(args[i]); }
712
+ }
713
+
714
+ const [command, ...rest] = cleanArgs;
715
+
716
+ if (!command || command === 'help') {
717
+ console.log(`FOS Tools v${VERSION} — Frontier OS App Builder CLI
718
+
719
+ Usage: node fos-tools.cjs <command> [args] [--raw] [--pick <field>]
720
+
721
+ Commands:
722
+ init <workflow> [phase] Compound context loader
723
+ state load Load STATE.md
724
+ state json STATE.md frontmatter as JSON
725
+ state update <field> <value> Update STATE.md field
726
+ state get <field> Get STATE.md field
727
+ find-phase <N> Find phase directory by number
728
+ scaffold <template> [--vars '{}'] Render template with variable substitution
729
+ infer-modules "<description>" Map description to SDK modules
730
+ validate structure Check app structure matches spec
731
+ validate permissions Check permissions match SDK usage
732
+ commit "<message>" [--files ...] Git add + commit helper
733
+ version Show version
734
+ `);
735
+ return;
736
+ }
737
+
738
+ const cwd = process.cwd();
739
+
740
+ switch (command) {
741
+ case 'version':
742
+ output(flags.raw ? VERSION : { version: VERSION }, flags);
743
+ break;
744
+
745
+ case 'init':
746
+ cmdInit(rest[0], rest[1], flags);
747
+ break;
748
+
749
+ case 'state':
750
+ switch (rest[0]) {
751
+ case 'load': cmdStateLoad(cwd, flags); break;
752
+ case 'json': cmdStateJson(cwd, flags); break;
753
+ case 'update': cmdStateUpdate(cwd, rest[1], rest[2], flags); break;
754
+ case 'get': cmdStateGet(cwd, rest[1], flags); break;
755
+ default: error('Unknown state subcommand. Valid: load, json, update, get');
756
+ }
757
+ break;
758
+
759
+ case 'find-phase':
760
+ cmdFindPhase(cwd, rest[0], flags);
761
+ break;
762
+
763
+ case 'scaffold': {
764
+ const varsIdx = rest.indexOf('--vars');
765
+ const varsJson = varsIdx >= 0 ? rest[varsIdx + 1] : null;
766
+ cmdScaffold(rest[0], varsJson, flags);
767
+ break;
768
+ }
769
+
770
+ case 'infer-modules':
771
+ cmdInferModules(rest.join(' '), flags);
772
+ break;
773
+
774
+ case 'validate':
775
+ switch (rest[0]) {
776
+ case 'structure': cmdValidateStructure(cwd, flags); break;
777
+ case 'permissions': cmdValidatePermissions(cwd, flags); break;
778
+ default: error('Unknown validate subcommand. Valid: structure, permissions');
779
+ }
780
+ break;
781
+
782
+ case 'commit': {
783
+ const filesIdx = rest.indexOf('--files');
784
+ const files = filesIdx >= 0 ? rest.slice(filesIdx + 1) : [];
785
+ cmdCommit(rest[0], files, flags);
786
+ break;
787
+ }
788
+
789
+ default:
790
+ error(`Unknown command: ${command}. Run 'node fos-tools.cjs help' for usage.`);
791
+ }
792
+ }
793
+
794
+ main();