Skip to content

Commit 88000f4

Browse files
committed
feat: enhance AI client with local API key configuration, add sharing options for project updates, and implement shipping block logic in task management
1 parent 07806bf commit 88000f4

19 files changed

Lines changed: 830 additions & 32 deletions

File tree

packages/cli/src/ai/client.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { createAnthropic } from "@ai-sdk/anthropic";
22
import { streamObject } from "ai";
33
import { type ZodSchema } from "zod";
4-
import { readConfig } from "../storage/local.js";
4+
import * as p from "@clack/prompts";
5+
import { readConfig, writeConfig } from "../storage/local.js";
56
import { getCachedResult, setCachedResult } from "../storage/cache.js";
7+
import { colors } from "../ui/theme.js";
68

79
interface ResolvedAuth {
810
anthropicKey: string | null;
@@ -12,7 +14,7 @@ interface ResolvedAuth {
1214
function resolveAuth(): ResolvedAuth {
1315
const config = readConfig();
1416
return {
15-
anthropicKey: process.env.ANTHROPIC_API_KEY || null,
17+
anthropicKey: process.env.ANTHROPIC_API_KEY || config.anthropicKey || null,
1618
token: config.auth?.apiKey || null,
1719
};
1820
}
@@ -110,6 +112,44 @@ export async function generateStructured<T extends object>(options: {
110112
throw new Error("Your session has expired. Please run `loopkit auth` to log in again.");
111113
}
112114

115+
if (res.status === 402 || res.status === 403) {
116+
console.log(`\n${colors.warning("⚠️ Hosted AI requires a Solo/Pro subscription.")}`);
117+
console.log(colors.muted("You can upgrade your plan or Bring Your Own Key (BYOK) for free.\n"));
118+
119+
const useByok = await p.confirm({
120+
message: "Would you like to configure your local Anthropic API Key?",
121+
active: "Yes, enter key",
122+
inactive: "No, cancel",
123+
});
124+
125+
if (!p.isCancel(useByok) && useByok) {
126+
const apiKey = await p.text({
127+
message: "Enter your Anthropic API Key (starts with sk-ant-):",
128+
placeholder: "sk-ant-...",
129+
validate: (val) => {
130+
if (!val.startsWith("sk-ant-")) {
131+
return "Invalid key format. Key must start with 'sk-ant-'.";
132+
}
133+
if (val.length < 20) {
134+
return "API key seems too short.";
135+
}
136+
return undefined;
137+
},
138+
});
139+
140+
if (!p.isCancel(apiKey) && apiKey) {
141+
const config = readConfig();
142+
config.anthropicKey = apiKey;
143+
writeConfig(config);
144+
console.log(colors.success("API key saved securely. Retrying request...\n"));
145+
146+
return generateStructured(options);
147+
}
148+
}
149+
150+
throw new Error("Hosted AI is disabled on the free tier. Please upgrade or provide a local API key.");
151+
}
152+
113153
const data = await res.json();
114154
if (!res.ok) {
115155
throw new Error(data.error || "Failed to generate AI response from LoopKit servers.");

packages/cli/src/commands/celebrate.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
readPulseResponses,
1313
} from "../storage/local.js";
1414
import { computeLoopKitScore } from "../analytics/score.js";
15-
import { buildProofCard, buildTweetLine, copyToClipboard } from "../ui/proof-card.js";
15+
import { buildProofCard, buildTweetLine, copyToClipboard, buildTwitterIntentUrl, openUrl } from "../ui/proof-card.js";
1616
import { colors, header, box, pass, info } from "../ui/theme.js";
1717
import { getConvexProjectId } from "../storage/sync.js";
1818

@@ -240,12 +240,27 @@ export async function celebrateCommand(
240240
console.log(header("Share"));
241241
console.log(colors.dim(" Copy this to share your progress:"));
242242
console.log(box(shareText));
243-
console.log(colors.dim(` Tweet: ${tweetLine}`));
244243

245-
// Auto-copy to clipboard on macOS
246-
const copied = await copyToClipboard(tweetLine);
247-
if (copied) {
248-
console.log(pass("Tweet line copied to clipboard — paste and share!"));
244+
const shareAction = await p.select({
245+
message: "Share this week's win:",
246+
options: [
247+
{ value: "twitter", label: "[t]weet on X — open twitter.com/intent/tweet" },
248+
{ value: "copy", label: "[c]opy tweet line to clipboard" },
249+
{ value: "skip", label: "[s]kip" },
250+
],
251+
});
252+
253+
if (!p.isCancel(shareAction)) {
254+
if (shareAction === "twitter") {
255+
const copied = await copyToClipboard(tweetLine);
256+
const twitterUrl = buildTwitterIntentUrl(tweetLine);
257+
await openUrl(twitterUrl);
258+
console.log(pass("Opened X/Twitter in browser!"));
259+
if (copied) console.log(colors.dim(" (Also copied to clipboard as fallback.)"));
260+
} else if (shareAction === "copy") {
261+
const copied = await copyToClipboard(tweetLine);
262+
if (copied) console.log(pass("Tweet line copied to clipboard — paste and share!"));
263+
}
249264
}
250265

251266
// ─── Share to public wins feed (if --share flag) ─────────────────

packages/cli/src/commands/loop.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { predictSuccess, renderPrediction } from "../analytics/predictor.js";
2828
import { detectPatterns } from "../analytics/patterns.js";
2929
import { getPriorityMoment, recordMomentShown } from "../analytics/coach.js";
3030
import { computeLoopKitScore, renderLoopKitScore, readLoopKitScoreFromLog } from "../analytics/score.js";
31-
import { buildProofCard, buildTweetLine, copyToClipboard } from "../ui/proof-card.js";
31+
import { buildProofCard, buildTweetLine, copyToClipboard, buildTwitterIntentUrl, openUrl } from "../ui/proof-card.js";
3232
import { colors, header, box, pass, warn, info, nextStep, scoreBar, shortcutsHint, emptyState, patternCard, coachingCard } from "../ui/theme.js";
3333

3434
interface LoopProof {
@@ -495,13 +495,27 @@ export async function loopCommand(options?: { revenue?: string; async?: boolean
495495
);
496496

497497
const postAction = await p.select({
498-
message: "Post:",
498+
message: "Share this post:",
499499
options: [
500-
{ value: "use", label: "[u]se as-is" },
501-
{ value: "skip", label: "[s]kip" },
500+
{ value: "twitter", label: "[t]weet on X — open twitter.com/intent/tweet" },
501+
{ value: "copy", label: "[c]opy to clipboard" },
502+
{ value: "skip", label: "[s]kip" },
502503
],
503504
});
504505

506+
if (!p.isCancel(postAction)) {
507+
if (postAction === "twitter") {
508+
const bipCopied = await copyToClipboard(synthesis.bipPost);
509+
const twitterUrl = buildTwitterIntentUrl(synthesis.bipPost);
510+
await openUrl(twitterUrl);
511+
console.log(pass("Opened X/Twitter in browser."));
512+
if (bipCopied) console.log(colors.dim(" (Also copied to clipboard as fallback.)"));
513+
} else if (postAction === "copy") {
514+
const bipCopied = await copyToClipboard(synthesis.bipPost);
515+
if (bipCopied) console.log(pass("BIP post copied to clipboard."));
516+
}
517+
}
518+
505519
// ─── Proof Card (GF-2) ───────────────────────────────────
506520
const latestMRR = getLatestMRR();
507521
const proofCardData = {
@@ -523,11 +537,24 @@ export async function loopCommand(options?: { revenue?: string; async?: boolean
523537

524538
console.log(header("Proof Card"));
525539
console.log(box(proofCardText, `Week ${weekNum} Card`));
526-
console.log(colors.dim(` Tweet: ${tweetLine}`));
527540

528-
const copied = await copyToClipboard(tweetLine);
529-
if (copied) {
530-
console.log(pass("Tweet line copied to clipboard — paste and share!"));
541+
const cardCopied = await copyToClipboard(tweetLine);
542+
if (cardCopied) {
543+
console.log(pass("Tweet line copied to clipboard."));
544+
}
545+
546+
const cardShareAction = await p.select({
547+
message: "Share proof card:",
548+
options: [
549+
{ value: "twitter", label: "[t]weet on X — open twitter.com/intent/tweet" },
550+
{ value: "skip", label: "[s]kip" },
551+
],
552+
});
553+
554+
if (!p.isCancel(cardShareAction) && cardShareAction === "twitter") {
555+
const twitterUrl = buildTwitterIntentUrl(tweetLine);
556+
await openUrl(twitterUrl);
557+
console.log(pass("Opened X/Twitter in browser!"));
531558
}
532559

533560

packages/cli/src/commands/track.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
saveStandup,
1919
readStandup,
2020
getStandupStreak,
21+
getLastShipDate,
22+
getProjectCreationDate,
2123
} from "../storage/local.js";
2224
import { computeBenchmarks, renderBenchmarks } from "../analytics/benchmarks.js";
2325
import { getSnoozeWarning } from "../analytics/oracle.js";
@@ -59,9 +61,9 @@ export async function trackCommand(options?: {
5961
// ─── --add: Quick task add ────────────────────────────────────
6062
if (options?.add) {
6163
if (typeof options.add === "string") {
62-
addTask(slug, options.add);
64+
await addTask(slug, options.add);
6365
} else {
64-
addTasksViaEditor(slug);
66+
await addTasksViaEditor(slug);
6567
}
6668
return;
6769
}
@@ -118,6 +120,16 @@ export async function trackCommand(options?: {
118120

119121
// ─── Board View ───────────────────────────────────────────────
120122
console.log(shortcutsHint());
123+
124+
const lastShip = getLastShipDate(slug);
125+
const refDate = lastShip || getProjectCreationDate(slug);
126+
const now = new Date();
127+
const diffTime = now.getTime() - refDate.getTime();
128+
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
129+
if (diffDays > 14) {
130+
console.log(colors.danger(` ⚠️ SHIPPING BLOCK ACTIVE: You haven't shipped in ${diffDays} days! Task addition is locked unless overridden.\n`));
131+
}
132+
121133
console.log(header("This Week"));
122134

123135
if (visibleOpen.length === 0 && done.length === 0 && snoozedActive.length === 0) {
@@ -390,9 +402,53 @@ function parseTasks(content: string): ParsedTask[] {
390402
return tasks;
391403
}
392404

405+
async function handleShippingBlock(slug: string): Promise<boolean> {
406+
const lastShip = getLastShipDate(slug);
407+
const refDate = lastShip || getProjectCreationDate(slug);
408+
const now = new Date();
409+
410+
const diffTime = now.getTime() - refDate.getTime();
411+
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
412+
413+
if (diffDays > 14) {
414+
console.log(colors.danger(`\n⚠️ SHIPPING BLOCK: You haven't shipped in ${diffDays} days!`));
415+
console.log(colors.muted("To maintain momentum, you should ship at least once every 14 days.\n"));
416+
417+
const override = await p.confirm({
418+
message: "Do you want to override this block and add the task anyway?",
419+
active: "Yes, override",
420+
inactive: "No, cancel",
421+
});
422+
423+
if (p.isCancel(override) || !override) {
424+
console.log(colors.warning("Task addition canceled. Run `loopkit ship` first."));
425+
return false;
426+
}
427+
428+
const reason = await p.text({
429+
message: "Please enter a reason for overriding this shipping block:",
430+
placeholder: "e.g., waiting on third-party API approval",
431+
validate: (value) => (value.trim().length < 5 ? "Reason must be at least 5 characters." : undefined),
432+
});
433+
434+
if (p.isCancel(reason) || !reason) {
435+
console.log(colors.warning("Task addition canceled. Reason required."));
436+
return false;
437+
}
438+
439+
console.log(colors.success(`Override registered. Reason: "${reason}"`));
440+
}
441+
return true;
442+
}
443+
393444
// ─── Task Operations ─────────────────────────────────────────────
394445

395-
function addTask(slug: string, title: string): void {
446+
async function addTask(slug: string, title: string, skipBlockCheck = false): Promise<void> {
447+
if (!skipBlockCheck) {
448+
const allowed = await handleShippingBlock(slug);
449+
if (!allowed) return;
450+
}
451+
396452
let content = readTasksFile(slug);
397453
if (!content) {
398454
createTasksScaffold(slug, slug);
@@ -424,7 +480,10 @@ function addTask(slug: string, title: string): void {
424480
console.log(pass(`Added #${newId}: ${title}`));
425481
}
426482

427-
function addTasksViaEditor(slug: string): void {
483+
async function addTasksViaEditor(slug: string): Promise<void> {
484+
const allowed = await handleShippingBlock(slug);
485+
if (!allowed) return;
486+
428487
const editor =
429488
process.env.EDITOR ||
430489
process.env.VISUAL ||
@@ -460,7 +519,7 @@ function addTasksViaEditor(slug: string): void {
460519
}
461520

462521
for (const title of lines) {
463-
addTask(slug, title);
522+
await addTask(slug, title, true);
464523
}
465524
}
466525

packages/cli/src/storage/__tests__/storage.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import {
5252
getStandupStreak,
5353
getStandupPath,
5454
getStandupDir,
55+
getLastShipDate,
56+
getProjectCreationDate,
5557
} from "../local";
5658

5759
const TEST_BASE = fs.mkdtempSync(path.join(os.tmpdir(), "loopkit-test-"));
@@ -766,3 +768,51 @@ describe("Token Encryption", () => {
766768
}
767769
});
768770
});
771+
772+
describe("Ship and Project Creation Dates", () => {
773+
beforeEach(() => {
774+
cleanupTestDir();
775+
fs.mkdirSync(TEST_BASE);
776+
setCwdToTestDir();
777+
});
778+
779+
afterEach(() => cleanupTestDir());
780+
781+
it("getLastShipDate returns null when no ship logs exist", () => {
782+
ensureProjectDir("my-app");
783+
expect(getLastShipDate("my-app")).toBeNull();
784+
});
785+
786+
it("getLastShipDate returns correct date when matching ship logs exist", () => {
787+
ensureProjectDir("my-app");
788+
saveBrief("my-app", {
789+
name: "CoolApp",
790+
problem: "test",
791+
icp: "test",
792+
whyUnsolved: "test",
793+
mvp: "test",
794+
});
795+
796+
saveShipLog("**Product:** CoolApp\n**What shipped:** landing page", "2026-05-01");
797+
saveShipLog("**Product:** CoolApp\n**What shipped:** payment gateway", "2026-05-10");
798+
saveShipLog("**Product:** OtherApp\n**What shipped:** irrelevant stuff", "2026-05-15");
799+
800+
const lastShip = getLastShipDate("my-app");
801+
expect(lastShip).not.toBeNull();
802+
expect(lastShip?.toISOString().split("T")[0]).toBe("2026-05-10");
803+
});
804+
805+
it("getProjectCreationDate falls back to file stats when brief.json is missing createdAt", () => {
806+
ensureProjectDir("my-app");
807+
saveBrief("my-app", {
808+
name: "CoolApp",
809+
problem: "test",
810+
icp: "test",
811+
whyUnsolved: "test",
812+
mvp: "test",
813+
});
814+
815+
const creationDate = getProjectCreationDate("my-app");
816+
expect(creationDate).toBeInstanceOf(Date);
817+
});
818+
});

0 commit comments

Comments
 (0)