trainerroad-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ import { compactAnnotationRecord } from "../lib/planning-normalizers.mjs";
2
+
3
+ export async function commandAnnotations(flags, deps) {
4
+ const {
5
+ resolveQueryContext,
6
+ requirePrivateContext,
7
+ applyAgentRecordFilters,
8
+ toRecordsOnlyPayload,
9
+ isJsonMode,
10
+ writeOutput,
11
+ hasAgentRecordTransforms,
12
+ } = deps;
13
+
14
+ const context = await resolveQueryContext(flags);
15
+ requirePrivateContext(context, "annotations");
16
+
17
+ const fullRecords = Array.isArray(context.timeline?.annotations)
18
+ ? context.timeline.annotations
19
+ : [];
20
+ const baseRecords = flags.full ? fullRecords : fullRecords.map((record) => compactAnnotationRecord(record));
21
+ const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(baseRecords, flags);
22
+
23
+ const payload = {
24
+ mode: "private",
25
+ generatedAt: new Date().toISOString(),
26
+ command: "annotations",
27
+ query: { full: Boolean(flags.full) },
28
+ filters: filterSummary,
29
+ member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
30
+ count: filteredRecords.length,
31
+ records: filteredRecords,
32
+ };
33
+ const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
34
+
35
+ if (!isJsonMode(flags)) {
36
+ await writeOutput(outputPayload, flags, (value) => {
37
+ const lines = [`Annotations (${value.count})`];
38
+ if (hasAgentRecordTransforms(flags) || flags["records-only"] || flags.full) {
39
+ for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
40
+ if (value.filters) lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
41
+ return lines.join("\n");
42
+ }
43
+ for (const annotation of value.records) {
44
+ lines.push(
45
+ `- ${annotation.dateOnly ?? "(unknown-date)"} type=${annotation.typeLabel ?? annotation.typeId} durationDays=${annotation.durationDays ?? "n/a"}`,
46
+ );
47
+ }
48
+ return lines.join("\n");
49
+ });
50
+ return;
51
+ }
52
+ await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
53
+ }
@@ -0,0 +1,28 @@
1
+ export async function commandLogin(flags, deps) {
2
+ const { withClient, readPasswordFromStdin, writeOutput } = deps;
3
+ const client = await withClient(flags);
4
+ const password =
5
+ flags["password-stdin"]
6
+ ? await readPasswordFromStdin()
7
+ : flags.password ?? process.env.TR_PASSWORD ?? null;
8
+ const result = await client.login({
9
+ username: flags.username ?? process.env.TR_USERNAME ?? null,
10
+ password,
11
+ returnPath: flags["return-path"] ?? "/app/career/quinnsprouse",
12
+ });
13
+ await writeOutput(result, { ...flags, json: true });
14
+ }
15
+
16
+ export async function commandWhoAmI(flags, deps) {
17
+ const { withClient, writeOutput } = deps;
18
+ const client = await withClient(flags);
19
+ const info = await client.getMemberInfo();
20
+ await writeOutput(info, { ...flags, json: true });
21
+ }
22
+
23
+ export async function commandLogout(flags, deps) {
24
+ const { withClient, writeOutput } = deps;
25
+ const client = await withClient(flags);
26
+ await client.clearSession();
27
+ await writeOutput({ ok: true, message: "Session cleared." }, { ...flags, json: true });
28
+ }
@@ -0,0 +1,165 @@
1
+ import {
2
+ AGENT_FILTER_OPTIONS,
3
+ AGENT_OUTPUT_OPTIONS,
4
+ COMMANDS,
5
+ FILTERABLE_COMMANDS,
6
+ } from "../lib/command-manifest.mjs";
7
+
8
+ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
9
+ const commandEntries = Object.entries(COMMANDS)
10
+ .filter(([name]) => !commandFilter || name === commandFilter)
11
+ .map(([name, def]) => ({
12
+ name,
13
+ summary: def.summary,
14
+ usage: level >= 2 ? def.usage : undefined,
15
+ supportsAgentFilters: FILTERABLE_COMMANDS.has(name),
16
+ agentFilters: level >= 3 && FILTERABLE_COMMANDS.has(name) ? AGENT_FILTER_OPTIONS : undefined,
17
+ agentOutputOptions:
18
+ level >= 3 && FILTERABLE_COMMANDS.has(name) ? AGENT_OUTPUT_OPTIONS : undefined,
19
+ }));
20
+
21
+ const payload = {
22
+ generatedAt: new Date().toISOString(),
23
+ progressiveDisclosureLevel: level,
24
+ discoveryFlow: {
25
+ level1: "Find command families and choose auth mode.",
26
+ level2: "Pick command and run with --json for machine output.",
27
+ level3: "Apply --from/--to/--type/--contains/--min-tss/--max-tss/--sort/--result-limit/--fields.",
28
+ },
29
+ firstSteps: [
30
+ "node src/cli.mjs capabilities --json",
31
+ "node src/cli.mjs whoami --json",
32
+ "node src/cli.mjs future --days 30 --json",
33
+ "node src/cli.mjs help future --json",
34
+ ],
35
+ commandCount: commandEntries.length,
36
+ commands: commandEntries,
37
+ };
38
+
39
+ if (level >= 3) {
40
+ payload.agentPatterns = [
41
+ {
42
+ pattern: "Summarize future workouts in a date window",
43
+ command:
44
+ "node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --fields id,title,tss,date --sort date --json",
45
+ },
46
+ {
47
+ pattern: "Find hard completed rides",
48
+ command:
49
+ "node src/cli.mjs past --days 90 --details --min-tss 80 --sort tss-desc --result-limit 20 --jsonl",
50
+ },
51
+ {
52
+ pattern: "Extract only fields for downstream tools",
53
+ command:
54
+ "node src/cli.mjs today --details --fields recordType,name,started,tss --json",
55
+ },
56
+ ];
57
+ }
58
+
59
+ return payload;
60
+ }
61
+
62
+ export async function commandDiscover(flags, deps) {
63
+ const { requirePositiveInteger, isJsonMode, writeOutput } = deps;
64
+ const level = requirePositiveInteger(flags.level, 1);
65
+ const boundedLevel = Math.min(Math.max(level, 1), 3);
66
+ const commandFilter = flags.command ? String(flags.command).trim() : null;
67
+ if (commandFilter && !COMMANDS[commandFilter]) {
68
+ throw new Error(`Unknown command for --command: ${commandFilter}`);
69
+ }
70
+
71
+ const payload = buildDiscoveryPayload(boundedLevel, commandFilter);
72
+ if (!isJsonMode(flags)) {
73
+ await writeOutput(payload, flags, (value) => {
74
+ const lines = [
75
+ `Discovery level: ${value.progressiveDisclosureLevel}`,
76
+ "Flow:",
77
+ `- L1: ${value.discoveryFlow.level1}`,
78
+ `- L2: ${value.discoveryFlow.level2}`,
79
+ `- L3: ${value.discoveryFlow.level3}`,
80
+ `Commands returned: ${value.commandCount}`,
81
+ ];
82
+ for (const command of value.commands) {
83
+ lines.push(`- ${command.name}: ${command.summary}`);
84
+ }
85
+ lines.push("Tip: add --json for structured discovery payload.");
86
+ return lines.join("\n");
87
+ });
88
+ return;
89
+ }
90
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
91
+ }
92
+
93
+ export async function commandCapabilities(flags, deps) {
94
+ const { writeOutput } = deps;
95
+ const payload = {
96
+ generatedAt: new Date().toISOString(),
97
+ authModes: {
98
+ private: {
99
+ method: "cookie-session + anti-forgery form post",
100
+ browserRequired: false,
101
+ flow: [
102
+ "GET /app/login?ReturnUrl=...",
103
+ "parse __RequestVerificationToken + ReturnUrl",
104
+ "POST /app/login (x-www-form-urlencoded)",
105
+ "store SharedTrainerRoadAuth cookie",
106
+ ],
107
+ },
108
+ public: {
109
+ method: "username-based unauthenticated endpoint access",
110
+ browserRequired: false,
111
+ endpoint: "GET /app/api/tss/{username}",
112
+ limitations: [
113
+ "No detailed workout names/durations from public endpoint",
114
+ "No private react-calendar detail endpoints without auth",
115
+ ],
116
+ },
117
+ },
118
+ endpointCoverage: {
119
+ privateFull: [
120
+ "GET /app/api/member-info",
121
+ "GET /app/api/react-calendar/{memberId}/timeline",
122
+ "GET /app/api/react-calendar/{memberId}/activities (ids header)",
123
+ "GET /app/api/react-calendar/{memberId}/planned-activities (ids header)",
124
+ "GET /app/api/react-calendar/{memberId}/personal-records (ids header)",
125
+ "GET /app/api/career/{memberId}/levels",
126
+ "GET /app/api/career/{username}/new",
127
+ "GET /app/api/weight-history/{memberId}/all",
128
+ "GET /app/api/plan-builder/current-custom-plan/{username}",
129
+ "GET /app/api/plan-builder/{username}/all-user-plans",
130
+ "GET /app/api/plan-builder/{username}/plan-phases",
131
+ "GET /app/api/ai-ftp-detection/can-use-ai-ftp/{memberId}",
132
+ "GET /app/api/calendar/aiftp/{memberId}/ai-failure-status",
133
+ "GET /app/api/seasons/{memberId}",
134
+ "GET /app/api/onboarding/power-ranking?memberId={memberId}",
135
+ "GET /app/api/onboarding/personal-records?startTime=...&endTime=...",
136
+ "POST /app/api/personal-records/for-date-range/{memberId}?rowType=...&indoorOnly=...",
137
+ ],
138
+ publicLimited: [
139
+ "GET /app/api/tss/{username} (day-level TSS + FTP history)",
140
+ ],
141
+ },
142
+ commands: Object.keys(COMMANDS),
143
+ agentFeatures: {
144
+ progressiveDisclosure: [
145
+ "discover --level 1",
146
+ "discover --level 2",
147
+ "discover --level 3 --json",
148
+ "help <command> --json",
149
+ ],
150
+ filterableCommands: Array.from(FILTERABLE_COMMANDS),
151
+ filterOptions: AGENT_FILTER_OPTIONS,
152
+ outputOptions: AGENT_OUTPUT_OPTIONS,
153
+ },
154
+ outputModes: ["text", "json", "jsonl"],
155
+ };
156
+
157
+ await writeOutput(payload, flags, () => {
158
+ return [
159
+ "Capabilities:",
160
+ "- Private mode (authenticated): full timeline + workout details.",
161
+ "- Public mode (unauthenticated): day-level TSS/ride/planned signals + FTP history.",
162
+ "- Commands support both via automatic mode selection and --target/--public flags.",
163
+ ].join("\n");
164
+ });
165
+ }
@@ -0,0 +1,52 @@
1
+ import { compactEventRecord } from "../lib/planning-normalizers.mjs";
2
+
3
+ export async function commandEvents(flags, deps) {
4
+ const {
5
+ resolveQueryContext,
6
+ requirePrivateContext,
7
+ applyAgentRecordFilters,
8
+ toRecordsOnlyPayload,
9
+ isJsonMode,
10
+ writeOutput,
11
+ hasAgentRecordTransforms,
12
+ } = deps;
13
+
14
+ const context = await resolveQueryContext(flags);
15
+ requirePrivateContext(context, "events");
16
+
17
+ const fullRecords = Array.isArray(context.timeline?.events) ? context.timeline.events : [];
18
+ const baseRecords = flags.full ? fullRecords : fullRecords.map((record) => compactEventRecord(record));
19
+ const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(baseRecords, flags);
20
+
21
+ const payload = {
22
+ mode: "private",
23
+ generatedAt: new Date().toISOString(),
24
+ command: "events",
25
+ query: { full: Boolean(flags.full) },
26
+ filters: filterSummary,
27
+ member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
28
+ count: filteredRecords.length,
29
+ records: filteredRecords,
30
+ };
31
+ const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
32
+
33
+ if (!isJsonMode(flags)) {
34
+ await writeOutput(outputPayload, flags, (value) => {
35
+ const lines = [`Events (${value.count})`];
36
+ if (hasAgentRecordTransforms(flags) || flags["records-only"] || flags.full) {
37
+ for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
38
+ if (value.filters) lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
39
+ return lines.join("\n");
40
+ }
41
+ for (const event of value.records) {
42
+ const when = event.dateOnly ?? event.started ?? "(unknown-date)";
43
+ lines.push(
44
+ `- ${when} ${event.name ?? "(unnamed-event)"} | priority=${event.racePriority ?? "n/a"} | tss=${event.tss ?? "n/a"}`,
45
+ );
46
+ }
47
+ return lines.join("\n");
48
+ });
49
+ return;
50
+ }
51
+ await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
52
+ }
@@ -0,0 +1,210 @@
1
+ export async function commandFtp(flags, deps) {
2
+ const {
3
+ requirePositiveInteger,
4
+ resolveQueryContext,
5
+ normalizeFtpHistory,
6
+ getLastItem,
7
+ isJsonMode,
8
+ writeOutput,
9
+ } = deps;
10
+
11
+ const historyLimit = requirePositiveInteger(flags["history-limit"], 50);
12
+ const context = await resolveQueryContext(flags);
13
+ let historySource = context.publicTss ?? null;
14
+
15
+ if (context.mode === "private") {
16
+ try {
17
+ historySource = await context.client.getPublicTssByUsername(context.memberInfo.username);
18
+ } catch {
19
+ historySource = null;
20
+ }
21
+ }
22
+
23
+ const fullHistory = normalizeFtpHistory(
24
+ historySource?.ftpRecordsDate ?? historySource?.FtpRecordsDate ?? [],
25
+ );
26
+ const records = historyLimit > 0 ? fullHistory.slice(-historyLimit) : fullHistory;
27
+ const latest = getLastItem(fullHistory);
28
+
29
+ if (context.mode === "private") {
30
+ const payload = {
31
+ mode: "private",
32
+ generatedAt: new Date().toISOString(),
33
+ command: "ftp",
34
+ member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
35
+ currentFtp: context.memberInfo.ftp ?? latest?.value ?? null,
36
+ ftpHistoryCount: fullHistory.length,
37
+ query: { historyLimit },
38
+ records,
39
+ };
40
+
41
+ if (!isJsonMode(flags)) {
42
+ await writeOutput(payload, flags, (value) => {
43
+ const lines = [
44
+ `FTP: ${value.currentFtp ?? "unknown"} [private mode]`,
45
+ `History points: ${value.ftpHistoryCount}`,
46
+ ];
47
+ for (const item of value.records) {
48
+ lines.push(`- ${item.dateOnly} ftp=${item.value}`);
49
+ }
50
+ return lines.join("\n");
51
+ });
52
+ return;
53
+ }
54
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
55
+ return;
56
+ }
57
+
58
+ const payload = {
59
+ mode: "public",
60
+ generatedAt: new Date().toISOString(),
61
+ command: "ftp",
62
+ member: { username: context.targetUsername },
63
+ currentFtp: latest?.value ?? null,
64
+ ftpHistoryCount: fullHistory.length,
65
+ query: { historyLimit },
66
+ records,
67
+ limitations: [
68
+ "Public mode can expose FTP history only when profile data is public.",
69
+ "No AI FTP detection or private progression internals in public mode.",
70
+ ],
71
+ };
72
+
73
+ if (!isJsonMode(flags)) {
74
+ await writeOutput(payload, flags, (value) => {
75
+ const lines = [
76
+ `FTP: ${value.currentFtp ?? "unknown"} [public mode]`,
77
+ `History points: ${value.ftpHistoryCount}`,
78
+ ];
79
+ for (const item of value.records) {
80
+ lines.push(`- ${item.dateOnly} ftp=${item.value}`);
81
+ }
82
+ lines.push(`Limitations: ${value.limitations.join(" ")}`);
83
+ return lines.join("\n");
84
+ });
85
+ return;
86
+ }
87
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
88
+ }
89
+
90
+ export async function commandFtpPrediction(flags, deps) {
91
+ const {
92
+ resolveQueryContext,
93
+ requirePrivateContext,
94
+ toIsoDate,
95
+ isoDateShift,
96
+ normalizeFitnessThresholds,
97
+ dateOnlyDiffDays,
98
+ countPlannedWorkoutsInRange,
99
+ isJsonMode,
100
+ writeOutput,
101
+ } = deps;
102
+
103
+ const context = await resolveQueryContext(flags);
104
+ requirePrivateContext(context, "ftp-prediction");
105
+
106
+ const [eligibility, failureStatus, levels, timeline] = await Promise.all([
107
+ context.client.getAiFtpEligibility(context.memberInfo.memberId, context.memberInfo.username),
108
+ context.client.getAiFtpFailureStatus(context.memberInfo.memberId, context.memberInfo.username),
109
+ context.client.getCareerLevels(context.memberInfo.memberId, context.memberInfo.username),
110
+ context.client.getTimeline(context.memberInfo.memberId, context.memberInfo.username),
111
+ ]);
112
+
113
+ const detection = eligibility?.additionalData?.detection ?? {};
114
+ const projectedProgressionLevels = Array.isArray(detection?.projectedProgressionLevels)
115
+ ? detection.projectedProgressionLevels
116
+ : [];
117
+ const currentProgressionLevels = Array.isArray(detection?.currentProgressionLevels)
118
+ ? detection.currentProgressionLevels
119
+ : [];
120
+ const nextAiFtpAvailability = eligibility?.additionalData?.nextAiFtpAvailability ?? null;
121
+ const nextAiFtpAvailabilityDateOnly = nextAiFtpAvailability ? toIsoDate(nextAiFtpAvailability) : null;
122
+ const todayDateOnly = isoDateShift(0);
123
+ const fitnessThresholds = normalizeFitnessThresholds(timeline?.fitnessThresholds ?? []);
124
+ const currentFtpRaw = detection?.ftp ?? context.memberInfo.ftp ?? null;
125
+ const currentFtp = Number.isFinite(Number(currentFtpRaw)) ? Number(currentFtpRaw) : null;
126
+
127
+ let predictedThreshold = null;
128
+ if (nextAiFtpAvailabilityDateOnly) {
129
+ const matching = fitnessThresholds.filter((row) => row.dateOnly === nextAiFtpAvailabilityDateOnly);
130
+ if (matching.length > 0) predictedThreshold = matching[matching.length - 1];
131
+ }
132
+ if (!predictedThreshold) {
133
+ const futureCandidates = fitnessThresholds.filter(
134
+ (row) => row.dateOnly >= todayDateOnly && !row.isApplied,
135
+ );
136
+ if (futureCandidates.length > 0) predictedThreshold = futureCandidates[0];
137
+ }
138
+
139
+ const predictedFtp = predictedThreshold?.value ?? null;
140
+ const predictionDate = predictedThreshold?.date ?? nextAiFtpAvailability ?? null;
141
+ const predictionDateOnly = predictedThreshold?.dateOnly ?? nextAiFtpAvailabilityDateOnly ?? null;
142
+ const daysUntilPrediction =
143
+ predictionDateOnly != null ? dateOnlyDiffDays(todayDateOnly, predictionDateOnly) : null;
144
+ const ftpDelta =
145
+ Number.isFinite(currentFtp) && Number.isFinite(predictedFtp) ? predictedFtp - currentFtp : null;
146
+ const ftpDeltaPercent =
147
+ ftpDelta != null && currentFtp && currentFtp !== 0
148
+ ? Math.round((ftpDelta / currentFtp) * 100)
149
+ : null;
150
+ const plannedWorkoutCount =
151
+ predictionDateOnly != null
152
+ ? countPlannedWorkoutsInRange(timeline?.plannedActivities ?? [], todayDateOnly, predictionDateOnly)
153
+ : null;
154
+
155
+ const payload = {
156
+ mode: "private",
157
+ generatedAt: new Date().toISOString(),
158
+ command: "ftp-prediction",
159
+ member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
160
+ canUseAiFtp: Boolean(eligibility?.can),
161
+ reasonCode: eligibility?.reason ?? null,
162
+ modelVersion: eligibility?.modelVersion ?? detection?.modelVersion ?? null,
163
+ detectionFtp: detection?.ftp ?? null,
164
+ currentFtp,
165
+ predictedFtp,
166
+ predictionDate,
167
+ predictionDateOnly,
168
+ daysUntilPrediction,
169
+ ftpDelta,
170
+ ftpDeltaPercent,
171
+ plannedWorkoutCount,
172
+ nextAiFtpAvailability,
173
+ nextAiFtpAvailabilityDateOnly,
174
+ lastViewed: eligibility?.additionalData?.lastViewed ?? null,
175
+ aiFailureStatus: failureStatus?.status ?? null,
176
+ projectedProgressionLevels,
177
+ currentProgressionLevels,
178
+ levels: levels?.levels ?? {},
179
+ levelsTimestamp: levels?.timestamp ?? null,
180
+ predictionThresholdSource: predictedThreshold,
181
+ futureFitnessThresholds: fitnessThresholds.filter((row) => row.dateOnly >= todayDateOnly),
182
+ records: projectedProgressionLevels,
183
+ };
184
+
185
+ if (!isJsonMode(flags)) {
186
+ await writeOutput(payload, flags, (value) => {
187
+ const lines = [
188
+ `AI FTP usable: ${value.canUseAiFtp}`,
189
+ `Reason code: ${value.reasonCode}`,
190
+ `Current FTP: ${value.currentFtp ?? value.detectionFtp ?? "unknown"}`,
191
+ `Predicted FTP: ${value.predictedFtp ?? "unknown"}`,
192
+ `Prediction date: ${value.predictionDateOnly ?? value.nextAiFtpAvailabilityDateOnly ?? "unknown"}`,
193
+ `Days until prediction: ${value.daysUntilPrediction ?? "unknown"}`,
194
+ `FTP delta: ${value.ftpDelta ?? "unknown"} (${value.ftpDeltaPercent ?? "unknown"}%)`,
195
+ `Planned workouts in window: ${value.plannedWorkoutCount ?? "unknown"}`,
196
+ `AI failure status: ${value.aiFailureStatus}`,
197
+ ];
198
+ if (value.nextAiFtpAvailability) lines.push(`Next AI FTP availability: ${value.nextAiFtpAvailability}`);
199
+ lines.push(`Projected progression updates: ${value.projectedProgressionLevels.length}`);
200
+ for (const item of value.projectedProgressionLevels) {
201
+ lines.push(
202
+ `- progressionId=${item.progressionId} from=${item.previousDisplayLevel} to=${item.displayFinalLevel}`,
203
+ );
204
+ }
205
+ return lines.join("\n");
206
+ });
207
+ return;
208
+ }
209
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
210
+ }
@@ -0,0 +1,58 @@
1
+ import { buildLevelsByZone } from "../lib/planning-normalizers.mjs";
2
+
3
+ export async function commandLevels(flags, deps) {
4
+ const {
5
+ resolveQueryContext,
6
+ requirePrivateContext,
7
+ applyAgentRecordFilters,
8
+ toRecordsOnlyPayload,
9
+ isJsonMode,
10
+ writeOutput,
11
+ hasAgentRecordTransforms,
12
+ } = deps;
13
+
14
+ const context = await resolveQueryContext(flags);
15
+ requirePrivateContext(context, "levels");
16
+
17
+ const [levelsPayload, eligibilityPayload] = await Promise.all([
18
+ context.client.getCareerLevels(context.memberInfo.memberId, context.memberInfo.username),
19
+ context.client.getAiFtpEligibility(context.memberInfo.memberId, context.memberInfo.username),
20
+ ]);
21
+ const records = buildLevelsByZone(levelsPayload, eligibilityPayload);
22
+ const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(records, flags);
23
+
24
+ const payload = {
25
+ mode: "private",
26
+ generatedAt: new Date().toISOString(),
27
+ command: "levels",
28
+ filters: filterSummary,
29
+ member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
30
+ levelsTimestamp: levelsPayload?.timestamp ?? null,
31
+ aiModelVersion:
32
+ eligibilityPayload?.modelVersion ??
33
+ eligibilityPayload?.additionalData?.detection?.modelVersion ??
34
+ null,
35
+ count: filteredRecords.length,
36
+ records: filteredRecords,
37
+ };
38
+ const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
39
+
40
+ if (!isJsonMode(flags)) {
41
+ await writeOutput(outputPayload, flags, (value) => {
42
+ const lines = [`Progression levels (${value.count})`];
43
+ if (hasAgentRecordTransforms(flags) || flags["records-only"]) {
44
+ for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
45
+ if (value.filters) lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
46
+ return lines.join("\n");
47
+ }
48
+ for (const record of value.records) {
49
+ lines.push(
50
+ `- ${record.zoneLabel} | recent=${record.recentLevel ?? "n/a"} | aiCurrent=${record.aiCurrentDisplayLevel ?? "n/a"} | aiProjected=${record.aiProjectedDisplayLevel ?? "n/a"} | delta=${record.aiDelta ?? "n/a"}`,
51
+ );
52
+ }
53
+ return lines.join("\n");
54
+ });
55
+ return;
56
+ }
57
+ await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
58
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ compactCurrentPlan,
3
+ compactPlanPhase,
4
+ compactPlanSummary,
5
+ toIsoDate,
6
+ } from "../lib/planning-normalizers.mjs";
7
+
8
+ export async function commandPlan(flags, deps) {
9
+ const {
10
+ resolveQueryContext,
11
+ requirePrivateContext,
12
+ applyAgentRecordFilters,
13
+ toRecordsOnlyPayload,
14
+ isJsonMode,
15
+ writeOutput,
16
+ hasAgentRecordTransforms,
17
+ } = deps;
18
+
19
+ const context = await resolveQueryContext(flags);
20
+ requirePrivateContext(context, "plan");
21
+
22
+ const view = String(flags.view ?? "phases").toLowerCase();
23
+ const validViews = new Set(["current", "phases", "plans"]);
24
+ if (!validViews.has(view)) {
25
+ throw new Error(`Invalid --view "${view}". Expected one of: current, phases, plans.`);
26
+ }
27
+
28
+ const [currentPlanRaw, allPlansRaw, phasesRaw] = await Promise.all([
29
+ context.client.getCurrentCustomPlan(context.memberInfo.username),
30
+ context.client.getAllUserPlans(context.memberInfo.username),
31
+ context.client.getPlanPhases(context.memberInfo.username),
32
+ ]);
33
+ const currentPlan = compactCurrentPlan(currentPlanRaw);
34
+ const plans = (Array.isArray(allPlansRaw) ? allPlansRaw : []).map((item) => compactPlanSummary(item));
35
+ const phases = (Array.isArray(phasesRaw) ? phasesRaw : []).map((item) => compactPlanPhase(item));
36
+
37
+ const viewRecords =
38
+ view === "current" ? (currentPlan ? [currentPlan] : []) : view === "plans" ? plans : phases;
39
+ const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(viewRecords, flags);
40
+
41
+ const payload = {
42
+ mode: "private",
43
+ generatedAt: new Date().toISOString(),
44
+ command: "plan",
45
+ query: { view, full: Boolean(flags.full) },
46
+ filters: filterSummary,
47
+ member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
48
+ counts: {
49
+ plans: plans.length,
50
+ phases: phases.length,
51
+ currentPlan: currentPlan ? 1 : 0,
52
+ },
53
+ currentPlan: flags.full || view === "current" ? currentPlan : compactCurrentPlan(currentPlanRaw),
54
+ plans: flags.full || view === "plans" ? plans : undefined,
55
+ phases: flags.full || view === "phases" ? phases : undefined,
56
+ count: filteredRecords.length,
57
+ records: filteredRecords,
58
+ };
59
+ const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
60
+
61
+ if (!isJsonMode(flags)) {
62
+ await writeOutput(outputPayload, flags, (value) => {
63
+ const lines = [
64
+ `Plan view=${value.query?.view ?? view} records=${value.count}`,
65
+ ];
66
+ if (hasAgentRecordTransforms(flags) || flags["records-only"]) {
67
+ for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
68
+ if (value.filters) lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
69
+ return lines.join("\n");
70
+ }
71
+ for (const item of value.records) {
72
+ lines.push(
73
+ `- ${item.name ?? item.planName ?? "(unnamed)"} ${item.dateOnly ?? "(no-date)"} -> ${item.end ? toIsoDate(item.end) : "n/a"}`,
74
+ );
75
+ }
76
+ return lines.join("\n");
77
+ });
78
+ return;
79
+ }
80
+ await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
81
+ }