a2acalling 0.6.5 → 0.6.6

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/cli.js CHANGED
@@ -12,11 +12,42 @@
12
12
  * a2a ping <url> Ping an invite URL
13
13
  * a2a gui Open the local dashboard GUI in a browser
14
14
  * a2a setup Auto setup (gateway-aware dashboard install)
15
+ * a2a uninstall Stop server and remove local A2A config
15
16
  */
16
17
 
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const readline = require('readline');
22
+ const { spawn } = require('child_process');
17
23
  const { TokenStore } = require('../src/lib/tokens');
18
24
  const { A2AClient } = require('../src/lib/client');
19
25
 
26
+ const CONFIG_DIR = process.env.A2A_CONFIG_DIR || process.env.OPENCLAW_CONFIG_DIR || path.join(os.homedir(), '.config', 'openclaw');
27
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'a2a-config.json');
28
+ const ONBOARDING_EXEMPT = new Set([
29
+ 'quickstart',
30
+ 'help',
31
+ 'version',
32
+ 'update',
33
+ 'uninstall',
34
+ 'onboard',
35
+ 'gui',
36
+ 'dashboard',
37
+ 'server',
38
+ 'setup',
39
+ 'install'
40
+ ]);
41
+
42
+ function isOnboarded() {
43
+ try {
44
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
45
+ return config.onboarding?.version === 2 && config.onboarding?.step === 'complete';
46
+ } catch (err) {
47
+ return false;
48
+ }
49
+ }
50
+
20
51
  // Lazy load conversation store (requires better-sqlite3)
21
52
  let convStore = null;
22
53
  function getConvStore() {
@@ -40,20 +71,20 @@ function getConvStore() {
40
71
 
41
72
  const store = new TokenStore();
42
73
 
43
- // Check onboarding status — warns but does not block
44
- function checkOnboarding(commandName) {
45
- try {
46
- const { A2AConfig } = require('../src/lib/config');
47
- const config = new A2AConfig();
48
- if (!config.isOnboarded()) {
49
- console.warn('\n\u26a0\ufe0f A2A onboarding not complete.');
50
- console.warn(' Run "a2a quickstart" to complete deterministic onboarding.');
51
- console.warn(' Without onboarding, invites may use default topics/goals and remote dashboard access may not be configured.\n');
52
- return false;
53
- }
54
- return true;
55
- } catch (e) {
56
- return true; // Don't block if config is broken
74
+ function enforceOnboarding(command) {
75
+ if (ONBOARDING_EXEMPT.has(command)) {
76
+ return;
77
+ }
78
+
79
+ if (!isOnboarded()) {
80
+ console.log('\n⚠️ A2A not configured yet.');
81
+ console.log('');
82
+ console.log('Run this first:');
83
+ console.log(' a2a quickstart --hostname YOUR_DOMAIN:PORT');
84
+ console.log('');
85
+ console.log('Example:');
86
+ console.log(' a2a quickstart --hostname myserver.com:3001');
87
+ process.exit(1);
57
88
  }
58
89
  }
59
90
 
@@ -71,8 +102,6 @@ function formatTimeAgo(date) {
71
102
  }
72
103
 
73
104
  function openInBrowser(url) {
74
- const { spawn } = require('child_process');
75
-
76
105
  const platform = process.platform;
77
106
  let cmd = null;
78
107
  let args = [];
@@ -162,6 +191,17 @@ function parseArgs(argv) {
162
191
  return args;
163
192
  }
164
193
 
194
+ async function promptYesNo(question) {
195
+ return await new Promise(resolve => {
196
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
197
+ rl.question(question, (answer) => {
198
+ rl.close();
199
+ const normalized = String(answer || '').trim().toLowerCase();
200
+ resolve(normalized === 'y' || normalized === 'yes');
201
+ });
202
+ });
203
+ }
204
+
165
205
  async function resolveInviteHostname() {
166
206
  const { resolveInviteHost } = require('../src/lib/invite-host');
167
207
 
@@ -184,7 +224,6 @@ async function resolveInviteHostname() {
184
224
  // Commands
185
225
  const commands = {
186
226
  create: async (args) => {
187
- checkOnboarding('create');
188
227
  // Parse max-calls: number, 'unlimited', or default (unlimited)
189
228
  let maxCalls = null; // Default: unlimited
190
229
  if (args.flags['max-calls']) {
@@ -753,7 +792,6 @@ https://github.com/onthegonow/a2a_calling`;
753
792
  },
754
793
 
755
794
  call: async (args) => {
756
- checkOnboarding('call');
757
795
  let target = args._[1];
758
796
  const message = args._.slice(2).join(' ') || args.flags.message || args.flags.m;
759
797
 
@@ -902,497 +940,705 @@ https://github.com/onthegonow/a2a_calling`;
902
940
 
903
941
  quickstart: async (args) => {
904
942
  const http = require('http');
905
- const https = require('https');
906
943
  const { A2AConfig } = require('../src/lib/config');
907
- const disc = require('../src/lib/disclosure');
944
+ const { isPortListening } = require('../src/lib/port-scanner');
945
+ const {
946
+ readContextFiles,
947
+ generateDefaultManifest,
948
+ saveManifest
949
+ } = require('../src/lib/disclosure');
908
950
  const {
909
951
  normalizeHostInput,
910
952
  splitHostPort,
911
953
  isLocalOrUnroutableHost
912
954
  } = require('../src/lib/invite-host');
913
- const { getExternalIp } = require('../src/lib/external-ip');
914
- const { CallbookStore } = require('../src/lib/callbook');
915
- const { isPortListening, tryBindPort } = require('../src/lib/port-scanner');
916
955
 
917
- const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
918
956
  const config = new A2AConfig();
957
+ const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
919
958
 
920
959
  if (args.flags.force) {
921
960
  config.resetOnboarding();
922
961
  }
923
962
 
924
- const backendPort = (() => {
925
- const raw = args.flags.port || process.env.A2A_PORT || process.env.PORT || 3001;
926
- const n = Number.parseInt(String(raw), 10);
927
- return (Number.isFinite(n) && n > 0 && n <= 65535) ? n : 3001;
928
- })();
929
-
930
- function looksLikePong(body) {
931
- try {
932
- const parsed = JSON.parse(String(body || ''));
933
- if (parsed && typeof parsed === 'object' && parsed.pong === true) return true;
934
- } catch (err) {
935
- // ignore
963
+ function parsePort(raw, fallback) {
964
+ const parsed = Number.parseInt(String(raw || '').trim(), 10);
965
+ if (Number.isFinite(parsed) && parsed > 0 && parsed <= 65535) {
966
+ return parsed;
936
967
  }
937
- return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
968
+ return fallback;
938
969
  }
939
970
 
940
- function fetchUrlText(url, timeoutMs = 5000) {
941
- return new Promise((resolve, reject) => {
942
- let parsed;
943
- try {
944
- parsed = new URL(url);
945
- } catch (err) {
946
- reject(new Error('invalid_url'));
947
- return;
971
+ function uniqueNonEmpty(values, limit = 80) {
972
+ const normalizeValue = (value) => {
973
+ if (typeof value === 'string') {
974
+ return String(value || '').trim();
948
975
  }
949
- const client = parsed.protocol === 'https:' ? https : http;
950
- const req = client.request({
951
- protocol: parsed.protocol,
952
- hostname: parsed.hostname,
953
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
954
- method: 'GET',
955
- path: parsed.pathname + parsed.search,
956
- headers: {
957
- 'User-Agent': `a2acalling/${process.env.npm_package_version || 'dev'} (quickstart)`
958
- },
959
- timeout: timeoutMs
960
- }, (res) => {
961
- let data = '';
962
- res.setEncoding('utf8');
963
- res.on('data', (chunk) => {
964
- data += chunk;
965
- if (data.length > 1024 * 256) {
966
- req.destroy(new Error('response_too_large'));
967
- }
968
- });
969
- res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: data }));
970
- });
971
- req.on('error', reject);
972
- req.on('timeout', () => req.destroy(new Error('timeout')));
973
- req.end();
974
- });
975
- }
976
-
977
- async function probeLocalPing(port, timeoutMs = 1000) {
978
- try {
979
- const res = await fetchUrlText(`http://127.0.0.1:${port}/api/a2a/ping`, timeoutMs);
980
- return { ok: looksLikePong(res.body), statusCode: res.statusCode, body: res.body };
981
- } catch (err) {
982
- return { ok: false, error: err && err.message ? err.message : 'request_failed' };
983
- }
984
- }
985
-
986
- async function externalPingCheck(targetUrl) {
987
- // Try direct access first. In practice this is the most reliable signal:
988
- // it avoids flaky third-party proxies and catches obvious scheme/port mistakes.
989
- try {
990
- const direct = await fetchUrlText(targetUrl, 2500);
991
- return { ok: looksLikePong(direct.body), provider: 'direct', statusCode: direct.statusCode };
992
- } catch (err) {
993
- // Fall back to remote fetch providers below.
994
- }
995
-
996
- const providers = [
997
- {
998
- name: 'allorigins',
999
- buildUrl: () => {
1000
- const u = new URL('https://api.allorigins.win/raw');
1001
- u.searchParams.set('url', targetUrl);
1002
- return u.toString();
1003
- }
1004
- },
1005
- {
1006
- name: 'jina',
1007
- buildUrl: () => `https://r.jina.ai/${targetUrl}`
976
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'topic' in value) {
977
+ return String(value.topic || '').trim();
1008
978
  }
1009
- ];
979
+ return '';
980
+ };
1010
981
 
1011
- for (const provider of providers) {
1012
- try {
1013
- // eslint-disable-next-line no-await-in-loop
1014
- const res = await fetchUrlText(provider.buildUrl(), 8000);
1015
- if (looksLikePong(res.body)) {
1016
- return { ok: true, provider: provider.name, statusCode: res.statusCode };
1017
- }
1018
- } catch (err) {
1019
- // try next
1020
- }
982
+ const out = [];
983
+ const seen = new Set();
984
+ for (const value of values) {
985
+ const text = normalizeValue(value);
986
+ if (!text) continue;
987
+ const key = text.toLowerCase();
988
+ if (seen.has(key)) continue;
989
+ seen.add(key);
990
+ out.push(text);
991
+ if (out.length >= limit) break;
1021
992
  }
1022
- return { ok: false };
993
+ return out;
1023
994
  }
1024
995
 
1025
- function slugify(value) {
1026
- return String(value || '')
1027
- .toLowerCase()
1028
- .replace(/['"]/g, '')
1029
- .replace(/[^a-z0-9]+/g, '-')
1030
- .replace(/^-+|-+$/g, '')
1031
- .slice(0, 60);
996
+ function normalizeTopicRecord(raw) {
997
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
998
+ return {
999
+ topic: String(raw.topic || '').trim(),
1000
+ detail: String(raw.detail || '').trim()
1001
+ };
1002
+ }
1003
+ return {
1004
+ topic: String(raw || '').trim(),
1005
+ detail: ''
1006
+ };
1032
1007
  }
1033
1008
 
1034
- function uniqueNonEmpty(items, limit = 24) {
1009
+ function uniqueTopicRecords(values, limit = 80) {
1035
1010
  const out = [];
1036
1011
  const seen = new Set();
1037
- for (const raw of items) {
1038
- const s = String(raw || '').trim();
1039
- if (!s) continue;
1040
- if (seen.has(s)) continue;
1041
- seen.add(s);
1042
- out.push(s);
1012
+ for (const value of values) {
1013
+ const item = normalizeTopicRecord(value);
1014
+ if (!item.topic) continue;
1015
+ const key = item.topic.toLowerCase();
1016
+ if (seen.has(key)) continue;
1017
+ seen.add(key);
1018
+ out.push(item);
1043
1019
  if (out.length >= limit) break;
1044
1020
  }
1045
1021
  return out;
1046
1022
  }
1047
1023
 
1048
- function extractSectionBullets(markdown, headingRegex) {
1049
- const text = String(markdown || '');
1050
- const match = text.match(new RegExp(`##\\s*(?:${headingRegex})[^\\n]*\\n([\\s\\S]*?)(?=\\n##|$)`, 'i'));
1051
- if (!match) return [];
1052
- return match[1]
1053
- .split('\n')
1054
- .map(l => l.trim())
1055
- .filter(l => l.startsWith('-') || l.startsWith('*'))
1056
- .map(l => l.replace(/^[\\s\\-\\*]+/, '').trim())
1057
- .filter(Boolean);
1024
+ function sanitizeSectionItems(values, limit = 80) {
1025
+ return uniqueTopicRecords(values, limit).map(item => ({
1026
+ topic: item.topic,
1027
+ detail: item.detail || ''
1028
+ }));
1058
1029
  }
1059
1030
 
1060
- function tierFromManifest(manifest, tier, fallback = []) {
1061
- const t = (manifest && manifest.topics && manifest.topics[tier]) ? manifest.topics[tier] : null;
1062
- if (!t) return fallback;
1063
- const items = []
1064
- .concat(Array.isArray(t.lead_with) ? t.lead_with : [])
1065
- .concat(Array.isArray(t.discuss_freely) ? t.discuss_freely : [])
1066
- .concat(Array.isArray(t.deflect) ? t.deflect : []);
1067
- const topics = items.map(x => (x && x.topic) ? x.topic : '').filter(Boolean);
1068
- return topics.length ? topics : fallback;
1031
+ function cloneDraft(draft = {}) {
1032
+ return JSON.parse(JSON.stringify(draft));
1069
1033
  }
1070
1034
 
1071
- function buildTierRecommendations(contextFiles, manifest) {
1072
- const publicFallback = ['chat', 'openclaw', 'a2a'];
1073
- const friendsFallback = ['chat', 'search', 'openclaw', 'a2a'];
1074
- const familyFallback = ['chat', 'search', 'openclaw', 'a2a', 'tools', 'memory'];
1035
+ function makeDraft(manifest) {
1036
+ const src = (manifest && manifest.topics) ? manifest.topics : {};
1037
+ return {
1038
+ public: {
1039
+ lead_with: sanitizeSectionItems((src.public && src.public.lead_with) || [], 60),
1040
+ discuss_freely: sanitizeSectionItems((src.public && src.public.discuss_freely) || [], 60),
1041
+ deflect: sanitizeSectionItems((src.public && src.public.deflect) || [], 60)
1042
+ },
1043
+ friends: {
1044
+ lead_with: sanitizeSectionItems((src.friends && src.friends.lead_with) || [], 60),
1045
+ discuss_freely: sanitizeSectionItems((src.friends && src.friends.discuss_freely) || [], 60),
1046
+ deflect: sanitizeSectionItems((src.friends && src.friends.deflect) || [], 60)
1047
+ },
1048
+ family: {
1049
+ lead_with: sanitizeSectionItems((src.family && src.family.lead_with) || [], 60),
1050
+ discuss_freely: sanitizeSectionItems((src.family && src.family.discuss_freely) || [], 60),
1051
+ deflect: sanitizeSectionItems((src.family && src.family.deflect) || [], 60)
1052
+ }
1053
+ };
1054
+ }
1075
1055
 
1076
- const rawPublic = tierFromManifest(manifest, 'public', publicFallback);
1077
- const rawFriends = tierFromManifest(manifest, 'friends', friendsFallback);
1078
- const rawFamily = tierFromManifest(manifest, 'family', familyFallback);
1056
+ function summarizeLine(content, maxLen = 60) {
1057
+ const text = String(content || '').split('\n').map((line) => line.trim()).find((line) => {
1058
+ return line && !line.startsWith('#') && !line.startsWith('---') && line.length <= 220;
1059
+ });
1060
+ if (!text) {
1061
+ return 'found';
1062
+ }
1063
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
1064
+ }
1079
1065
 
1080
- const goalsFromUser = extractSectionBullets(contextFiles.user, 'Goals|Current|Seeking|Working On');
1081
- const baseGoals = goalsFromUser.length
1082
- ? goalsFromUser
1083
- : ['grow network', 'find collaborators', 'build in public'];
1066
+ function countMemoryDocs(root) {
1067
+ try {
1068
+ const dir = path.join(root, 'memory');
1069
+ if (!fs.existsSync(dir)) return 0;
1070
+ return fs.readdirSync(dir).filter(name => name.endsWith('.md')).length;
1071
+ } catch (err) {
1072
+ return 0;
1073
+ }
1074
+ }
1084
1075
 
1085
- const publicTopics = uniqueNonEmpty(rawPublic.map(slugify).filter(Boolean), 16);
1086
- const friendsTopics = uniqueNonEmpty(rawFriends.map(slugify).filter(Boolean), 20);
1087
- const familyTopics = uniqueNonEmpty(rawFamily.map(slugify).filter(Boolean), 24);
1076
+ function renderWorkspaceScan(contextFiles) {
1077
+ const memoryCount = countMemoryDocs(workspaceDir);
1078
+ console.log('\n🔍 Scanning workspace for context...\n');
1079
+ console.log('Found:');
1080
+ const rows = [
1081
+ { label: 'USER.md', found: Boolean(contextFiles.user), note: summarizeLine(contextFiles.user, 72) },
1082
+ { label: 'SOUL.md', found: Boolean(contextFiles.soul), note: summarizeLine(contextFiles.soul, 72) },
1083
+ { label: 'HEARTBEAT.md', found: Boolean(contextFiles.heartbeat), note: 'contains agent tasks, not disclosure topics' },
1084
+ { label: 'SKILL.md', found: Boolean(contextFiles.skill), note: null },
1085
+ { label: 'memory/*.md', found: memoryCount > 0, note: `${memoryCount} file${memoryCount === 1 ? '' : 's'}` }
1086
+ ];
1088
1087
 
1089
- const goals = uniqueNonEmpty(baseGoals.map(slugify).filter(Boolean), 12);
1088
+ for (const row of rows) {
1089
+ const check = row.found ? '✅' : '❌';
1090
+ const note = row.found && row.note ? ` — ${row.note}` : '';
1091
+ const skip = row.label === 'HEARTBEAT.md' && row.found ? ' (skipped)' : '';
1092
+ console.log(` ${check} ${row.label}${skip}${note}`);
1093
+ }
1094
+ console.log('');
1095
+ }
1090
1096
 
1091
- return {
1092
- public: { topics: publicTopics, goals: goals.slice(0, 6) },
1093
- friends: { topics: uniqueNonEmpty([...publicTopics, ...friendsTopics], 24), goals: goals.slice(0, 8) },
1094
- family: { topics: uniqueNonEmpty([...publicTopics, ...friendsTopics, ...familyTopics], 30), goals }
1095
- };
1097
+ function sectionLabel(sectionName) {
1098
+ if (sectionName === 'lead_with') return 'Lead with';
1099
+ if (sectionName === 'discuss_freely') return 'Discuss freely';
1100
+ return 'Deflect';
1101
+ }
1102
+
1103
+ function flattenDraft(draft) {
1104
+ const flat = [];
1105
+ let index = 1;
1106
+ ['public', 'friends', 'family'].forEach((tier) => {
1107
+ ['lead_with', 'discuss_freely', 'deflect'].forEach((section) => {
1108
+ (draft[tier][section] || []).forEach((item, itemIndex) => {
1109
+ flat.push({
1110
+ index,
1111
+ tier,
1112
+ section,
1113
+ item,
1114
+ itemIndex,
1115
+ list: draft[tier][section]
1116
+ });
1117
+ index += 1;
1118
+ });
1119
+ });
1120
+ });
1121
+ return flat;
1096
1122
  }
1097
1123
 
1098
- function printTierSummary(tiers) {
1099
- const format = (t) => {
1100
- const topics = (t.topics || []).join(' · ') || '(none)';
1101
- const goals = (t.goals || []).join(' · ') || '(none)';
1102
- return `Topics: ${topics}\nGoals: ${goals}`;
1124
+ function renderDraft(draft, neverDisclose) {
1125
+ console.log('\n📋 Proposed Permission Tiers');
1126
+ console.log(''.repeat(60));
1127
+
1128
+ let index = 1;
1129
+ const titleByTier = {
1130
+ public: 'PUBLIC (anyone can see):',
1131
+ friends: 'FRIENDS (trusted contacts):',
1132
+ family: 'FAMILY (inner circle):'
1103
1133
  };
1104
- console.log('\nProposed permission tiers:\n');
1105
- console.log('PUBLIC');
1106
- console.log(format(tiers.public));
1107
- console.log('\nFRIENDS');
1108
- console.log(format(tiers.friends));
1109
- console.log('\nFAMILY');
1110
- console.log(format(tiers.family));
1111
- console.log('');
1134
+
1135
+ ['public', 'friends', 'family'].forEach((tier) => {
1136
+ console.log(`\n${titleByTier[tier]}`);
1137
+ ['lead_with', 'discuss_freely', 'deflect'].forEach((section) => {
1138
+ console.log(` ${sectionLabel(section)}:`);
1139
+ const list = draft[tier][section] || [];
1140
+ if (list.length === 0) {
1141
+ console.log(' (none)');
1142
+ return;
1143
+ }
1144
+ list.forEach((item) => {
1145
+ const detail = item.detail ? ` — ${item.detail}` : '';
1146
+ console.log(` ${index}. ${item.topic}${detail}`);
1147
+ index += 1;
1148
+ });
1149
+ });
1150
+ });
1151
+
1152
+ console.log('\nNEVER DISCLOSE:');
1153
+ const staticNever = (neverDisclose || ['API keys', 'Other users\' data', 'Financial figures']);
1154
+ staticNever.forEach((item) => console.log(` • ${item}`));
1155
+ console.log('═'.repeat(60));
1156
+ return flattenDraft(draft);
1112
1157
  }
1113
1158
 
1114
- // ── Step 1: Background bootstrap (config + manifest) ─────────
1115
- let contextFiles = {};
1116
- let manifest = {};
1117
- try {
1118
- contextFiles = disc.readContextFiles(workspaceDir);
1119
- const forceManifest = Boolean(args.flags.force || args.flags['regen-manifest'] || args.flags.regenManifest);
1120
- if (forceManifest) {
1121
- // Force-regen uses minimal starter; agent-driven extraction is the
1122
- // proper way to populate topics (via `a2a onboard --submit`).
1123
- const generated = disc.generateDefaultManifest();
1124
- disc.saveManifest(generated);
1125
- manifest = generated;
1126
- } else {
1127
- manifest = disc.loadManifest();
1128
- if (!manifest || Object.keys(manifest).length === 0) {
1129
- const generated = disc.generateDefaultManifest();
1130
- disc.saveManifest(generated);
1131
- manifest = generated;
1132
- }
1133
- }
1134
- } catch (err) {
1135
- // Non-fatal: onboarding can proceed even if manifest fails.
1136
- contextFiles = {};
1137
- manifest = {};
1138
- }
1139
-
1140
- console.log('\nA2A deterministic onboarding');
1141
- console.log('──────────────────────────');
1142
-
1143
- // ── Step 2: Owner dashboard access (local + optional remote) ─
1144
- config.setOnboarding({ step: 'access' });
1145
-
1146
- const hostnameFlagRaw = args.flags.hostname !== undefined ? String(args.flags.hostname) : '';
1147
- const normalizedHostname = normalizeHostInput(hostnameFlagRaw);
1148
-
1149
- // Invite host controls the a2a:// hostname we hand out (and remote dashboard pairing URL).
1150
- let inviteHost = '';
1151
- if (normalizedHostname) {
1152
- const parsed = splitHostPort(normalizedHostname);
1153
- const publicPortRaw = args.flags['public-port'] || args.flags.publicPort || process.env.A2A_PUBLIC_PORT || 443;
1154
- const publicPort = Number.parseInt(String(publicPortRaw), 10);
1155
- inviteHost = parsed.port
1156
- ? normalizedHostname
1157
- : `${parsed.hostname}:${(Number.isFinite(publicPort) && publicPort > 0 && publicPort <= 65535) ? publicPort : 443}`;
1158
- config.setAgent({ hostname: inviteHost });
1159
- } else {
1160
- const existing = normalizeHostInput((config.getAgent() || {}).hostname || '');
1161
- inviteHost = existing || `localhost:${backendPort}`;
1162
- if (!existing) {
1163
- config.setAgent({ hostname: inviteHost });
1164
- }
1159
+ function parseSections(target) {
1160
+ if (!target) return null;
1161
+ const [tierRaw, sectionRaw] = String(target).toLowerCase().split('.');
1162
+ if (!tierRaw || !sectionRaw) return null;
1163
+ if (!['public', 'friends', 'family'].includes(tierRaw)) return null;
1164
+
1165
+ const section = {
1166
+ lead: 'lead_with',
1167
+ lead_with: 'lead_with',
1168
+ discuss: 'discuss_freely',
1169
+ discuss_freely: 'discuss_freely',
1170
+ deflect: 'deflect'
1171
+ }[sectionRaw];
1172
+
1173
+ if (!section) return null;
1174
+ return { tier: tierRaw, section };
1165
1175
  }
1166
1176
 
1167
- const inviteParsed = splitHostPort(inviteHost);
1168
- const invitePort = inviteParsed.port;
1169
- const schemeOverride = String(process.env.A2A_PUBLIC_SCHEME || '').trim();
1170
- const inviteScheme = schemeOverride || ((!invitePort || invitePort === 443) ? 'https' : 'http');
1171
- const expectedPingUrl = `${inviteScheme}://${inviteHost}/api/a2a/ping`;
1172
- const inviteLooksLocal = isLocalOrUnroutableHost(inviteParsed.hostname);
1177
+ function splitCommand(input) {
1178
+ const raw = String(input || '').trim();
1179
+ if (!raw) return [];
1180
+ const match = raw.match(/"([^"]*)"|'([^']*)'|`([^`]*)`|\S+/g);
1181
+ if (!match) return [];
1182
+ return match.map((token) => {
1183
+ if ((token.startsWith('"') && token.endsWith('"')) ||
1184
+ (token.startsWith("'") && token.endsWith("'")) ||
1185
+ (token.startsWith('`') && token.endsWith('`'))) {
1186
+ return token.slice(1, -1);
1187
+ }
1188
+ return token;
1189
+ });
1190
+ }
1173
1191
 
1174
- console.log('\n2️⃣ Owner dashboard access');
1175
- console.log(`Local dashboard: http://127.0.0.1:${backendPort}/dashboard/`);
1176
- console.log(`Invite host: ${inviteHost}`);
1192
+ function findByIndex(draft, index) {
1193
+ const target = flattenDraft(draft).find(item => item.index === index);
1194
+ return target || null;
1195
+ }
1177
1196
 
1178
- if (inviteLooksLocal) {
1179
- console.log('Remote dashboard: not configured (invite host looks local/unroutable)');
1180
- console.log(' To enable remote access, rerun with: --hostname YOUR_DOMAIN:443');
1181
- } else {
1182
- const callbookStore = new CallbookStore();
1183
- if (!callbookStore.isAvailable()) {
1184
- console.log('Remote dashboard: Callbook Remote not available (storage unavailable)');
1185
- console.log(` Hint: ${callbookStore.getDbError ? callbookStore.getDbError() : 'storage_unavailable'}`);
1186
- } else {
1187
- const label = String(args.flags['device-label'] || args.flags.deviceLabel || 'Callbook Remote').trim().slice(0, 120);
1188
- const ttlHoursRaw = args.flags['callbook-ttl-hours'] || args.flags.callbookTtlHours || 24;
1189
- const ttlHours = Math.max(1, Math.min(168, Number.parseInt(String(ttlHoursRaw), 10) || 24));
1190
- const created = callbookStore.createProvisionCode({ label, ttlMs: ttlHours * 60 * 60 * 1000 });
1191
- if (created && created.success) {
1192
- const installUrl = `${inviteScheme}://${inviteHost}/callbook/install#code=${created.code}`;
1193
- console.log(`Remote dashboard: ${installUrl} (one-time, ${ttlHours}h)`);
1194
- } else {
1195
- console.log('Remote dashboard: failed to create install link');
1196
- console.log(` Hint: ${created && created.message ? created.message : (created && created.error ? created.error : 'unknown_error')}`);
1197
+ function readNameFromUserContext(content) {
1198
+ const lines = String(content || '').split('\n');
1199
+ for (const line of lines) {
1200
+ const trimmed = String(line || '').trim();
1201
+ if (!trimmed) continue;
1202
+
1203
+ const nameMatch = trimmed.match(/^\*{0,2}Name:\*{0,2}\s*(.+)$/i);
1204
+ if (nameMatch && nameMatch[1]) {
1205
+ return String(nameMatch[1]).trim();
1206
+ }
1207
+
1208
+ if (/^(owner|ownername):/i.test(trimmed)) {
1209
+ const ownerMatch = trimmed.replace(/^[^:]+:\s*/, '');
1210
+ if (ownerMatch) return ownerMatch.trim();
1211
+ }
1212
+
1213
+ if (trimmed.startsWith('-') || trimmed.startsWith('*') || trimmed.startsWith('#')) {
1214
+ continue;
1215
+ }
1216
+
1217
+ if (/^[A-Za-z][\w\-,.\s]{2,}$/i.test(trimmed)) {
1218
+ const candidate = trimmed.split('|')[0].split('\t')[0].trim();
1219
+ if (candidate && candidate.length <= 80) {
1220
+ return candidate;
1221
+ }
1197
1222
  }
1198
1223
  }
1224
+ return '';
1199
1225
  }
1200
1226
 
1201
- // ── Step 3: Permission tiers (topics + goals) ───────────────
1202
- const onboardingAfterAccess = config.getOnboarding();
1203
- if (!onboardingAfterAccess.tiers_confirmed) {
1204
- const recommendations = buildTierRecommendations(contextFiles, manifest);
1205
-
1206
- const parseFreeTextList = (raw) => {
1207
- if (raw === undefined || raw === null || raw === true) return [];
1208
- const text = String(raw || '').trim();
1209
- if (!text) return [];
1210
- return text
1211
- .split(/[\n,]+/g)
1212
- .map(s => s.trim())
1213
- .filter(Boolean);
1214
- };
1215
-
1216
- const promptLine = async (question) => {
1217
- const readline = require('readline');
1218
- return await new Promise(resolve => {
1219
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1220
- rl.question(question, (answer) => {
1221
- rl.close();
1222
- resolve(String(answer || '').trim());
1223
- });
1224
- });
1225
- };
1226
-
1227
- // Optional owner override: Friends tier topics/interests (most important tier).
1228
- const interactive = Boolean(
1229
- args.flags.interactive ||
1230
- args.flags['ask-friends-topics'] ||
1231
- args.flags.askFriendsTopics
1232
- );
1233
- let friendsTopicsOverride = parseFreeTextList(args.flags['friends-topics'] || args.flags.friendsTopics);
1234
- const noWorkspaceContext = !contextFiles.user && !contextFiles.heartbeat && !contextFiles.soul &&
1235
- !contextFiles.memory && !contextFiles.claude;
1236
- const shouldPromptFriendsTopics = (interactive || noWorkspaceContext) &&
1237
- friendsTopicsOverride.length === 0 &&
1238
- process.stdin.isTTY &&
1239
- process.stdout.isTTY;
1240
- if (shouldPromptFriendsTopics) {
1241
- const suggested = (recommendations.friends.topics || []).slice(0, 12).join(', ');
1242
- const answer = await promptLine(`Friends-tier topics/interests (comma-separated).\nSuggested: ${suggested}\n> `);
1243
- friendsTopicsOverride = parseFreeTextList(answer);
1244
- }
1245
-
1246
- if (friendsTopicsOverride.length > 0) {
1247
- const normalized = uniqueNonEmpty(friendsTopicsOverride.map(slugify).filter(Boolean), 24);
1248
- recommendations.friends.topics = uniqueNonEmpty(
1249
- [...(recommendations.public.topics || []), ...normalized],
1250
- 24
1251
- );
1252
- recommendations.family.topics = uniqueNonEmpty(
1253
- [...(recommendations.friends.topics || []), ...(recommendations.family.topics || [])],
1254
- 30
1255
- );
1256
- }
1257
-
1258
- try {
1259
- config.setTier('public', recommendations.public);
1260
- config.setTier('friends', recommendations.friends);
1261
- config.setTier('family', recommendations.family);
1262
- } catch (err) {
1263
- console.error('\n❌ Tier configuration validation failed.');
1264
- console.error(` ${err.message}`);
1265
- if (err.hint) {
1266
- console.error(` Hint: ${err.hint}`);
1267
- }
1268
- console.error('');
1269
- process.exit(1);
1270
- }
1271
-
1272
- printTierSummary(recommendations);
1273
-
1274
- config.setOnboarding({
1275
- step: 'tiers',
1276
- tiers_confirmed: true
1277
- });
1278
- }
1279
-
1280
- // ── Step 4: Port scan + reverse proxy guidance (if needed) ──
1281
- console.log('\n4️⃣ Port scan + reverse proxy');
1282
- console.log(`Invite host: ${inviteHost}`);
1283
- console.log(`Expected ping URL: ${expectedPingUrl}\n`);
1284
-
1285
- const expectsReverseProxy = Boolean(
1286
- (invitePort === 80 && backendPort !== 80) ||
1287
- ((!invitePort || invitePort === 443) && backendPort !== 443)
1288
- );
1227
+ function flattenTopicStrings(section) {
1228
+ return uniqueNonEmpty((section || []).map(item => String(item && item.topic || '').trim()), 200)
1229
+ .filter(Boolean);
1230
+ }
1289
1231
 
1290
- if (expectsReverseProxy) {
1291
- const port80Listening = await isPortListening(80, '127.0.0.1', { timeoutMs: 500 });
1292
- const port80Bind = await tryBindPort(80, '0.0.0.0');
1293
- const port80Ping = port80Listening.listening ? await probeLocalPing(80) : { ok: false };
1294
-
1295
- console.log('Port 80:');
1296
- if (port80Ping.ok) {
1297
- console.log(' ✅ serves /api/a2a/ping (A2A detected on :80)');
1298
- } else if (port80Listening.listening) {
1299
- console.log(` ⚠️ has a listener (${port80Listening.code || 'in_use'})`);
1300
- } else if (!port80Bind.ok && port80Bind.code === 'EACCES') {
1301
- console.log(' ⚠️ appears free but is not bindable by this user (EACCES)');
1302
- } else if (port80Bind.ok) {
1303
- console.log(' ✅ free and bindable by this user');
1304
- } else {
1305
- console.log(` ⚠️ not bindable (${port80Bind.code || 'unknown'})`);
1232
+ async function editLoop(draft, neverDisclose, reloadManifest) {
1233
+ const shouldPrompt = process.stdin.isTTY && process.stdout.isTTY;
1234
+ if (!shouldPrompt) {
1235
+ console.log('\n⏩ Non-interactive shell detected. Proceeding with proposed topics.');
1236
+ renderDraft(draft, neverDisclose);
1237
+ return draft;
1306
1238
  }
1307
1239
 
1308
- console.log('\nReverse proxy required (example routes):');
1309
- console.log(` /api/a2a/* -> http://127.0.0.1:${backendPort}`);
1310
- console.log(` /dashboard/* -> http://127.0.0.1:${backendPort}`);
1311
- console.log(` /callbook/* -> http://127.0.0.1:${backendPort}`);
1312
- console.log('');
1313
- console.log('If you have configured your reverse proxy and want to continue, run:');
1314
- console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --confirm-ingress`);
1315
- console.log('');
1316
- if (!args.flags['confirm-ingress']) {
1317
- return;
1318
- }
1319
- } else {
1320
- console.log('✅ No reverse proxy required based on invite host/port.');
1240
+ console.log('\nEdit commands:');
1241
+ console.log(' move N to TIER.SECTION — Move topic #N to a section');
1242
+ console.log(' remove N — Remove topic #N');
1243
+ console.log(' add TIER.SECTION "Topic" "Detail" Add a topic');
1244
+ console.log(' edit N topic "new" — Edit topic #N label');
1245
+ console.log(' edit N detail "new" — Edit topic #N detail');
1246
+ console.log(' reset — Rescan workspace and regenerate');
1247
+ console.log(' done — Save and continue\n');
1248
+
1249
+ let done = false;
1250
+ renderDraft(draft, neverDisclose);
1251
+
1252
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1253
+ return await new Promise((resolve) => {
1254
+ const finish = () => {
1255
+ if (!done) {
1256
+ done = true;
1257
+ resolve(draft);
1258
+ }
1259
+ };
1260
+
1261
+ const prompt = () => {
1262
+ rl.question('Your choice: ', (answer) => {
1263
+ const parts = splitCommand(answer);
1264
+ const command = String(parts[0] || '').toLowerCase();
1265
+ if (!parts.length) {
1266
+ renderDraft(draft, neverDisclose);
1267
+ return prompt();
1268
+ }
1269
+
1270
+ if (command === 'done') {
1271
+ rl.close();
1272
+ return finish();
1273
+ }
1274
+
1275
+ if (command === 'reset') {
1276
+ draft = cloneDraft(reloadManifest());
1277
+ renderDraft(draft, neverDisclose);
1278
+ return prompt();
1279
+ }
1280
+
1281
+ if (command === 'remove') {
1282
+ const target = findByIndex(draft, Number.parseInt(parts[1], 10));
1283
+ if (!target) {
1284
+ console.log(`Could not find topic #${parts[1]}.`);
1285
+ } else {
1286
+ target.list.splice(target.itemIndex, 1);
1287
+ console.log(`Removed topic #${parts[1]}.`);
1288
+ }
1289
+ renderDraft(draft, neverDisclose);
1290
+ return prompt();
1291
+ }
1292
+
1293
+ if (command === 'move') {
1294
+ const target = findByIndex(draft, Number.parseInt(parts[1], 10));
1295
+ const destination = parseSections(parts[2] === 'to' ? parts[3] : parts[2]);
1296
+ if (!target) {
1297
+ console.log(`Could not find topic #${parts[1]}.`);
1298
+ } else if (!destination) {
1299
+ console.log('Invalid target. Use format: move N to friends.lead');
1300
+ } else {
1301
+ target.list.splice(target.itemIndex, 1);
1302
+ draft[destination.tier][destination.section].push(target.item);
1303
+ console.log(`Moved topic #${parts[1]} to ${destination.tier}.${destination.section}`);
1304
+ }
1305
+ renderDraft(draft, neverDisclose);
1306
+ return prompt();
1307
+ }
1308
+
1309
+ if (command === 'add') {
1310
+ const destination = parseSections(parts[1]);
1311
+ const topic = parts[2];
1312
+ const detail = parts[3] || '';
1313
+ if (!destination || !topic) {
1314
+ console.log('Add format: add TIER.SECTION "Topic" "Detail"');
1315
+ } else {
1316
+ draft[destination.tier][destination.section].push({ topic, detail });
1317
+ console.log(`Added topic to ${destination.tier}.${destination.section}.`);
1318
+ }
1319
+ renderDraft(draft, neverDisclose);
1320
+ return prompt();
1321
+ }
1322
+
1323
+ if (command === 'edit') {
1324
+ const target = findByIndex(draft, Number.parseInt(parts[1], 10));
1325
+ const field = String(parts[2] || '').toLowerCase();
1326
+ const value = parts[3] || '';
1327
+ if (!target || !field || !['topic', 'detail'].includes(field)) {
1328
+ console.log('Edit format: edit N topic "new" | edit N detail "new"');
1329
+ } else {
1330
+ target.item[field] = value;
1331
+ console.log(`Updated topic #${parts[1]} ${field}.`);
1332
+ }
1333
+ renderDraft(draft, neverDisclose);
1334
+ return prompt();
1335
+ }
1336
+
1337
+ console.log('Unknown command.');
1338
+ renderDraft(draft, neverDisclose);
1339
+ return prompt();
1340
+ });
1341
+ };
1342
+
1343
+ rl.on('close', finish);
1344
+ prompt();
1345
+ });
1321
1346
  }
1322
1347
 
1323
- if (!config.getOnboarding().ingress_confirmed) {
1324
- config.setOnboarding({
1325
- step: 'ingress',
1326
- ingress_confirmed: true
1348
+ async function probePing(port) {
1349
+ return await new Promise((resolve) => {
1350
+ const req = http.request({
1351
+ hostname: '127.0.0.1',
1352
+ port,
1353
+ path: '/api/a2a/ping',
1354
+ method: 'GET',
1355
+ timeout: 1200
1356
+ }, (res) => {
1357
+ let body = '';
1358
+ res.setEncoding('utf8');
1359
+ res.on('data', chunk => { body += String(chunk || ''); });
1360
+ res.on('end', () => {
1361
+ const ok = body.includes('"pong":true') || body.includes('"pong": true');
1362
+ resolve({ ok, statusCode: res.statusCode || 0, body });
1363
+ });
1364
+ });
1365
+ req.on('error', () => resolve({ ok: false }));
1366
+ req.on('timeout', () => {
1367
+ req.destroy(new Error('timeout'));
1368
+ resolve({ ok: false });
1369
+ });
1370
+ req.end();
1327
1371
  });
1328
1372
  }
1329
1373
 
1330
- // ── Step 5: External IP + reachability check ───────────────
1331
- console.log('\n5️⃣ External IP + reachability check');
1374
+ async function waitForLocalServer(port) {
1375
+ for (let i = 0; i < 18; i++) {
1376
+ const listening = await isPortListening(port, '127.0.0.1', { timeoutMs: 250 });
1377
+ if (!listening.listening) {
1378
+ await new Promise(r => setTimeout(r, 250));
1379
+ continue;
1380
+ }
1332
1381
 
1333
- if (inviteLooksLocal) {
1334
- console.log('Skipping external IP probe: invite host looks local/unroutable.');
1382
+ const probe = await probePing(port);
1383
+ if (probe.ok) {
1384
+ return true;
1385
+ }
1386
+ }
1387
+ return false;
1388
+ }
1389
+
1390
+ async function startServer(port) {
1391
+ const listening = await isPortListening(port, '127.0.0.1', { timeoutMs: 250 });
1392
+ if (listening.listening) {
1393
+ return false;
1394
+ }
1395
+
1396
+ const serverScript = path.join(__dirname, '../src/server.js');
1397
+ const child = spawn(process.execPath, [serverScript], {
1398
+ env: {
1399
+ ...process.env,
1400
+ PORT: String(port),
1401
+ A2A_WORKSPACE: workspaceDir
1402
+ },
1403
+ detached: true,
1404
+ stdio: 'ignore'
1405
+ });
1406
+ child.unref();
1407
+ await new Promise(r => setTimeout(r, 300));
1408
+ return true;
1409
+ }
1410
+
1411
+ function looksLikePong(body) {
1412
+ try {
1413
+ const parsed = JSON.parse(String(body || ''));
1414
+ if (parsed && parsed.pong === true) return true;
1415
+ } catch (e) {}
1416
+ return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
1417
+ }
1418
+
1419
+ // Step 1: discover context
1420
+ const contextFiles = (() => {
1421
+ try {
1422
+ return readContextFiles(workspaceDir);
1423
+ } catch (err) {
1424
+ return {};
1425
+ }
1426
+ })();
1427
+
1428
+ renderWorkspaceScan(contextFiles);
1429
+
1430
+ const backendPort = parsePort(args.flags.port || args.flags.p || process.env.A2A_PORT || process.env.PORT, 3001);
1431
+ const hostFlag = normalizeHostInput(
1432
+ args.flags.hostname !== undefined
1433
+ ? String(args.flags.hostname)
1434
+ : (config.getAgent().hostname || `localhost:${backendPort}`)
1435
+ );
1436
+ const parsedHost = splitHostPort(hostFlag || `localhost:${backendPort}`);
1437
+ const inviteHost = parsedHost.port
1438
+ ? `${parsedHost.hostname}:${parsedHost.port}`
1439
+ : `${parsedHost.hostname || 'localhost'}:${backendPort}`;
1440
+
1441
+ // Step 2: seed draft from workspace context
1442
+ let manifest = generateDefaultManifest(contextFiles);
1443
+ let draft = makeDraft(manifest);
1444
+ const neverDisclose = uniqueNonEmpty(manifest.never_disclose || [
1445
+ 'API keys',
1446
+ 'Other users\' data',
1447
+ 'Financial figures'
1448
+ ], 30);
1449
+
1450
+ draft = await editLoop(draft, neverDisclose, () => {
1451
+ try {
1452
+ const refreshedContext = readContextFiles(workspaceDir);
1453
+ const freshManifest = generateDefaultManifest(refreshedContext);
1454
+ manifest = freshManifest;
1455
+ return makeDraft(freshManifest);
1456
+ } catch (err) {
1457
+ return draft;
1458
+ }
1459
+ });
1460
+
1461
+ const finalManifest = {
1462
+ version: 1,
1463
+ generated_at: manifest.generated_at || new Date().toISOString(),
1464
+ updated_at: new Date().toISOString(),
1465
+ topics: {
1466
+ public: {
1467
+ lead_with: sanitizeSectionItems(draft.public.lead_with, 80),
1468
+ discuss_freely: sanitizeSectionItems(draft.public.discuss_freely, 80),
1469
+ deflect: sanitizeSectionItems(draft.public.deflect, 80)
1470
+ },
1471
+ friends: {
1472
+ lead_with: sanitizeSectionItems(draft.friends.lead_with, 80),
1473
+ discuss_freely: sanitizeSectionItems(draft.friends.discuss_freely, 80),
1474
+ deflect: sanitizeSectionItems(draft.friends.deflect, 80)
1475
+ },
1476
+ family: {
1477
+ lead_with: sanitizeSectionItems(draft.family.lead_with, 80),
1478
+ discuss_freely: sanitizeSectionItems(draft.family.discuss_freely, 80),
1479
+ deflect: sanitizeSectionItems(draft.family.deflect, 80)
1480
+ }
1481
+ },
1482
+ never_disclose: neverDisclose,
1483
+ personality_notes: manifest.personality_notes || ''
1484
+ };
1485
+
1486
+ // Keep config in sync with the edited disclosure.
1487
+ try {
1488
+ config.setTier('public', {
1489
+ topics: flattenTopicStrings([...finalManifest.topics.public.lead_with, ...finalManifest.topics.public.discuss_freely, ...finalManifest.topics.public.deflect]),
1490
+ disclosure: 'public'
1491
+ });
1492
+
1493
+ config.setTier('friends', {
1494
+ topics: flattenTopicStrings([
1495
+ ...finalManifest.topics.public.lead_with,
1496
+ ...finalManifest.topics.public.discuss_freely,
1497
+ ...finalManifest.topics.public.deflect,
1498
+ ...finalManifest.topics.friends.lead_with,
1499
+ ...finalManifest.topics.friends.discuss_freely,
1500
+ ...finalManifest.topics.friends.deflect
1501
+ ]),
1502
+ disclosure: 'minimal'
1503
+ });
1504
+
1505
+ config.setTier('family', {
1506
+ topics: flattenTopicStrings([
1507
+ ...finalManifest.topics.public.lead_with,
1508
+ ...finalManifest.topics.public.discuss_freely,
1509
+ ...finalManifest.topics.public.deflect,
1510
+ ...finalManifest.topics.friends.lead_with,
1511
+ ...finalManifest.topics.friends.discuss_freely,
1512
+ ...finalManifest.topics.friends.deflect,
1513
+ ...finalManifest.topics.family.lead_with,
1514
+ ...finalManifest.topics.family.discuss_freely,
1515
+ ...finalManifest.topics.family.deflect
1516
+ ]),
1517
+ disclosure: 'minimal'
1518
+ });
1519
+
1520
+ saveManifest(finalManifest);
1521
+ config.setOnboarding({ step: 'tiers', tiers_confirmed: true });
1522
+ } catch (err) {
1523
+ console.error('\n❌ Failed to save tier updates.');
1524
+ console.error(` ${err.message}`);
1525
+ throw err;
1526
+ }
1527
+
1528
+ console.log('\n🚀 Starting A2A server...');
1529
+ console.log(`Port: ${backendPort}`);
1530
+ console.log(`Hostname: ${inviteHost}`);
1531
+
1532
+ const started = await startServer(backendPort);
1533
+ const localRunning = await waitForLocalServer(backendPort);
1534
+ if (!localRunning) {
1535
+ console.log('⚠️ Local server not reachable. Start it manually and retry if needed:');
1536
+ console.log(` A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`);
1335
1537
  } else {
1336
- const external = await getExternalIp({ forceRefresh: true });
1337
- if (external && external.ip) {
1338
- console.log(`External IP (${external.source || 'resolver'}): ${external.ip}`);
1339
- } else {
1340
- console.log(`External IP lookup failed: ${external && external.error ? external.error : 'unknown_error'}`);
1538
+ console.log('✅ Server running!');
1539
+ if (started) {
1540
+ console.log('🟢 Local server started automatically.');
1341
1541
  }
1342
1542
  }
1343
1543
 
1344
- const localListener = await isPortListening(backendPort, '127.0.0.1', { timeoutMs: 500 });
1345
- if (!localListener.listening) {
1346
- console.log('\n⚠️ A2A server is not reachable locally yet.');
1347
- console.log('Start it, then rerun quickstart:');
1348
- console.log(` A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`);
1349
- console.log('');
1350
- return;
1351
- }
1352
- const localPing = await probeLocalPing(backendPort, inviteLooksLocal ? 250 : 1000);
1353
- if (!localPing.ok) {
1354
- if (inviteLooksLocal) {
1355
- console.log(`\n⚠️ Port ${backendPort} is listening but /api/a2a/ping did not respond within a short timeout.`);
1356
- console.log('Continuing onboarding anyway (invite host is local/unroutable).');
1357
- } else {
1358
- console.log('\n⚠️ A2A server is not responding locally yet.');
1359
- console.log('Start it, then rerun quickstart:');
1360
- console.log(` A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`);
1361
- console.log('');
1362
- return;
1363
- }
1364
- }
1365
-
1366
- if (inviteLooksLocal) {
1367
- console.log('Skipping external reachability check: invite host looks local/unroutable.');
1368
- } else {
1369
- const extPing = await externalPingCheck(expectedPingUrl);
1370
- if (extPing.ok) {
1371
- console.log(`✅ External ping OK (${extPing.provider})`);
1372
- } else if (args.flags['skip-verify']) {
1373
- console.log('⚠️ External ping FAILED (skipped via --skip-verify).');
1374
- } else if (args.flags['confirm-ingress']) {
1375
- console.log('⚠️ External ping FAILED (continuing due to --confirm-ingress).');
1376
- } else {
1377
- console.log('⚠️ External ping FAILED (server may not be publicly reachable yet).');
1378
- console.log('Fix ingress (DNS/reverse proxy/firewall), then rerun. If you want to proceed anyway:');
1379
- console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --confirm-ingress`);
1380
- console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --skip-verify`);
1381
- console.log('');
1382
- return;
1383
- }
1384
- }
1385
-
1386
- if (!config.getOnboarding().verify_confirmed) {
1387
- config.setOnboarding({
1388
- step: 'verify',
1389
- verify_confirmed: true
1544
+ const dashboard = `http://127.0.0.1:${backendPort}/dashboard/`;
1545
+
1546
+ const hostSplit = splitHostPort(inviteHost);
1547
+ const isPrivateHost = isLocalOrUnroutableHost(hostSplit.hostname);
1548
+ const expectedPingUrl = `${isPrivateHost ? 'http' : (hostSplit.port === 443 ? 'https' : 'http')}://${inviteHost}/api/a2a/ping`;
1549
+
1550
+ if (isPrivateHost) {
1551
+ console.log('✅ External ping OK (local testing host)');
1552
+ } else {
1553
+ const external = await new Promise(resolve => {
1554
+ const req = http.get(expectedPingUrl, (res) => {
1555
+ let body = '';
1556
+ res.setEncoding('utf8');
1557
+ res.on('data', chunk => { body += chunk; });
1558
+ res.on('end', () => {
1559
+ resolve({ ok: looksLikePong(body), statusCode: res.statusCode || 0, body });
1560
+ });
1561
+ });
1562
+ req.on('error', () => resolve({ ok: false }));
1563
+ req.setTimeout(1500, () => {
1564
+ req.destroy(new Error('timeout'));
1565
+ resolve({ ok: false });
1566
+ });
1390
1567
  });
1568
+
1569
+ if (!external.ok && !args.flags['confirm-ingress'] && !args.flags['skip-verify']) {
1570
+ console.log('⚠️ External ping FAILED. Fix host/reachability and rerun quickstart, or use --skip-verify.');
1571
+ console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --skip-verify`);
1572
+ return;
1573
+ }
1574
+
1575
+ if (!external.ok) {
1576
+ console.log('⚠️ External ping FAILED (continuing).');
1577
+ } else {
1578
+ console.log(`✅ External ping OK (${expectedPingUrl})`);
1579
+ }
1391
1580
  }
1392
1581
 
1582
+ console.log(`Dashboard: ${dashboard}`);
1583
+
1584
+ // Step 5: generate first invite
1585
+ const publicTopicsForInvite = flattenTopicStrings([
1586
+ ...draft.public.lead_with,
1587
+ ...draft.public.discuss_freely
1588
+ ]);
1589
+ const goalItems = ['grow-network', 'find-collaborators', 'build-in-public'];
1590
+
1591
+ const ownerName = args.flags.owner || config.getAgent().name || readNameFromUserContext(contextFiles.user) || 'Someone';
1592
+ const peerName = args.flags.name || 'my-agent';
1593
+
1594
+ config.setAgent({ name: ownerName, hostname: inviteHost });
1595
+
1596
+ const { token, record } = store.create({
1597
+ name: peerName,
1598
+ owner: ownerName,
1599
+ permissions: 'public',
1600
+ disclosure: 'minimal',
1601
+ expires: 'never',
1602
+ maxCalls: null,
1603
+ allowedTopics: publicTopicsForInvite,
1604
+ allowedGoals: goalItems,
1605
+ notify: 'all'
1606
+ });
1607
+
1608
+ const inviteUrl = `a2a://${inviteHost}/${token}`;
1609
+ const topicLine = publicTopicsForInvite.length > 0 ? publicTopicsForInvite.slice(0, 6).join(' · ') : 'chat';
1610
+ const goalLine = goalItems.join(' · ');
1611
+
1612
+ console.log('\n📞 Your first invite (public tier):\n');
1613
+ console.log('─'.repeat(60));
1614
+ const inviteText = `📞🗣️ **Agent-to-Agent Call Invite**
1615
+
1616
+ 👤 **${ownerName}** would like your agent to call **${peerName}**
1617
+
1618
+ 💬 ${topicLine}
1619
+ 🎯 ${goalLine}
1620
+
1621
+ ${inviteUrl}
1622
+
1623
+ ── setup ──
1624
+ npm i -g a2acalling && a2a add "${inviteUrl}" "${peerName}" && a2a call "${peerName}" "Hello!"
1625
+ https://github.com/onthegonow/a2a_calling`;
1626
+ console.log(inviteText);
1627
+ console.log('─'.repeat(60));
1628
+ console.log('Share this invite to let other agents call you!\n');
1629
+
1393
1630
  config.completeOnboarding();
1394
- console.log('✅ Onboarding complete.');
1395
- console.log('Next: a2a gui or a2a create or a2a server');
1631
+
1632
+ console.log(' A2A setup complete!\n');
1633
+ console.log('Your agent is now:');
1634
+ console.log(` • Listening on ${inviteHost}`);
1635
+ console.log(' • Ready to receive calls');
1636
+ console.log(` • Configured with ${Object.keys(finalManifest.topics).length} permission tiers`);
1637
+ console.log('\nNext steps:');
1638
+ console.log(' a2a invite friends — Create a friends-tier invite');
1639
+ console.log(' a2a contacts — View your contacts');
1640
+ console.log(' a2a gui — Open the dashboard\n');
1641
+ console.log('Happy calling! 🤝');
1396
1642
  },
1397
1643
 
1398
1644
  install: () => {
@@ -1403,6 +1649,136 @@ https://github.com/onthegonow/a2a_calling`;
1403
1649
  require('../scripts/install-openclaw.js');
1404
1650
  },
1405
1651
 
1652
+ uninstall: async (args) => {
1653
+ const fs = require('fs');
1654
+ const path = require('path');
1655
+ const { spawnSync } = require('child_process');
1656
+
1657
+ const keepConfig = Boolean(args.flags['keep-config'] || args.flags.keepConfig);
1658
+ const force = Boolean(args.flags.force || args.flags.f);
1659
+
1660
+ const configDir = process.env.A2A_CONFIG_DIR ||
1661
+ process.env.OPENCLAW_CONFIG_DIR ||
1662
+ path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
1663
+
1664
+ const configFile = path.join(configDir, 'a2a-config.json');
1665
+ const disclosureFile = path.join(configDir, 'a2a-disclosure.json');
1666
+ const tokensFile = path.join(configDir, 'a2a-tokens.json');
1667
+ const dbFile = path.join(configDir, 'a2a-conversations.db');
1668
+ const logsDbFile = path.join(configDir, 'a2a-logs.db');
1669
+ const callbookDbFile = path.join(configDir, 'a2a-callbook.db');
1670
+
1671
+ console.log(`\n🗑️ A2A Uninstall`);
1672
+ console.log('─────────────────\n');
1673
+
1674
+ if (!keepConfig && !force) {
1675
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1676
+ console.error('Refusing to prompt without a TTY. Re-run with --force to confirm uninstall.');
1677
+ process.exit(1);
1678
+ }
1679
+
1680
+ const existing = [configFile, disclosureFile, tokensFile, dbFile, logsDbFile, callbookDbFile].filter(f => fs.existsSync(f));
1681
+ const list = existing.length ? existing.map(f => ` - ${f}`).join('\n') : ' (no local config/database files found)';
1682
+ const ok = await promptYesNo(
1683
+ `This will stop the pm2 process "a2a" and delete:\n${list}\nProceed? (y/N) `
1684
+ );
1685
+ if (!ok) {
1686
+ console.log('\nCancelled.\n');
1687
+ return;
1688
+ }
1689
+ }
1690
+
1691
+ function pm2Exists() {
1692
+ const res = spawnSync('pm2', ['--version'], { stdio: 'ignore', timeout: 4000 });
1693
+ if (res.error && res.error.code === 'ENOENT') return false;
1694
+ return res.status === 0;
1695
+ }
1696
+
1697
+ function pm2HasProcess(name) {
1698
+ const res = spawnSync('pm2', ['describe', name], { encoding: 'utf8', timeout: 6000 });
1699
+ if (res.error && res.error.code === 'ENOENT') return false;
1700
+ return res.status === 0;
1701
+ }
1702
+
1703
+ function pm2StopAndDelete(name) {
1704
+ if (!pm2Exists()) return { ok: true, skipped: true };
1705
+ if (!pm2HasProcess(name)) return { ok: true, skipped: true };
1706
+
1707
+ const stop = spawnSync('pm2', ['stop', name], { encoding: 'utf8', timeout: 8000 });
1708
+ if (stop.status !== 0) {
1709
+ const msg = (stop.stderr || stop.stdout || '').trim();
1710
+ return { ok: false, error: msg || 'pm2 stop failed' };
1711
+ }
1712
+
1713
+ const del = spawnSync('pm2', ['delete', name], { encoding: 'utf8', timeout: 8000 });
1714
+ if (del.status !== 0) {
1715
+ const msg = (del.stderr || del.stdout || '').trim();
1716
+ return { ok: false, error: msg || 'pm2 delete failed' };
1717
+ }
1718
+
1719
+ return { ok: true };
1720
+ }
1721
+
1722
+ function rmFileSafe(filePath) {
1723
+ try {
1724
+ fs.rmSync(filePath, { force: true });
1725
+ return { ok: true };
1726
+ } catch (err) {
1727
+ return { ok: false, error: err.message };
1728
+ }
1729
+ }
1730
+
1731
+ process.stdout.write('Stopping server... ');
1732
+ const stopped = pm2StopAndDelete('a2a');
1733
+ if (!stopped.ok) {
1734
+ console.log('❌');
1735
+ console.error(` ${stopped.error}`);
1736
+ process.exit(1);
1737
+ }
1738
+ console.log('✅');
1739
+
1740
+ let configOk = true;
1741
+ let dbOk = true;
1742
+
1743
+ if (!keepConfig) {
1744
+ process.stdout.write('Removing config... ');
1745
+ const c1 = rmFileSafe(configFile);
1746
+ const c2 = rmFileSafe(disclosureFile);
1747
+ const c3 = rmFileSafe(tokensFile);
1748
+ configOk = Boolean(c1.ok && c2.ok && c3.ok);
1749
+ console.log(configOk ? '✅' : '❌');
1750
+ if (!configOk) {
1751
+ if (!c1.ok) console.error(` ${configFile}: ${c1.error}`);
1752
+ if (!c2.ok) console.error(` ${disclosureFile}: ${c2.error}`);
1753
+ if (!c3.ok) console.error(` ${tokensFile}: ${c3.error}`);
1754
+ }
1755
+
1756
+ process.stdout.write('Removing database... ');
1757
+ const d1 = rmFileSafe(dbFile);
1758
+ const d2 = rmFileSafe(logsDbFile);
1759
+ const d3 = rmFileSafe(callbookDbFile);
1760
+ dbOk = Boolean(d1.ok && d2.ok && d3.ok);
1761
+ console.log(dbOk ? '✅' : '❌');
1762
+ if (!dbOk) {
1763
+ if (!d1.ok) console.error(` ${dbFile}: ${d1.error}`);
1764
+ if (!d2.ok) console.error(` ${logsDbFile}: ${d2.error}`);
1765
+ if (!d3.ok) console.error(` ${callbookDbFile}: ${d3.error}`);
1766
+ }
1767
+
1768
+ if (!configOk || !dbOk) {
1769
+ process.exit(1);
1770
+ }
1771
+ } else {
1772
+ console.log('Removing config... ⏭️');
1773
+ console.log('Removing database... ⏭️');
1774
+ }
1775
+
1776
+ console.log('\nTo complete removal:');
1777
+ console.log(' npm uninstall -g a2acalling\n');
1778
+ console.log(`Config preserved: ${keepConfig ? 'yes' : 'no'}`);
1779
+ console.log(`Location: ${configDir}`);
1780
+ },
1781
+
1406
1782
  update: async (args) => {
1407
1783
  const { execSync } = require('child_process');
1408
1784
  const path = require('path');
@@ -1578,6 +1954,11 @@ https://github.com/onthegonow/a2a_calling`;
1578
1954
  console.log(" a2a onboard --submit '<json>'\n");
1579
1955
  },
1580
1956
 
1957
+ version: () => {
1958
+ const pkg = require('../package.json');
1959
+ console.log(pkg.version);
1960
+ },
1961
+
1581
1962
  help: () => {
1582
1963
  console.log(`A2A Calling - Agent-to-Agent Communication
1583
1964
 
@@ -1657,6 +2038,10 @@ Server:
1657
2038
 
1658
2039
  install Install A2A for OpenClaw
1659
2040
  setup Auto setup (gateway-aware dashboard install)
2041
+ uninstall Stop server and remove local config/DB
2042
+ --keep-config Preserve config/DB (for reinstall)
2043
+ --force Skip confirmation prompt
2044
+ version Show installed package version
1660
2045
 
1661
2046
  Examples:
1662
2047
  a2a create --name "bappybot" --owner "Benjamin Pollack" --expires 7d
@@ -1680,6 +2065,8 @@ if (!commands[command]) {
1680
2065
  process.exit(1);
1681
2066
  }
1682
2067
 
2068
+ enforceOnboarding(command);
2069
+
1683
2070
  // Handle async commands
1684
2071
  const result = commands[command](args);
1685
2072
  if (result instanceof Promise) {