Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions packages/cli/src/commands/iterate.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { Command } from 'commander';
import kleur from 'kleur';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { configDir } from '@profullstack/sh1pt-core';
import { describeInput, resolveInput } from '../input.js';
import { agentsCmd } from './agents.js';

const GOALS_FILE = () => path.join(configDir(), 'iterate-goals.json');

async function loadGoals(): Promise<Record<string, string>> {
try {
const raw = await fs.readFile(GOALS_FILE(), 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw err;
}
}

async function saveGoals(goals: Record<string, string>): Promise<void> {
await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
const tmp = `${GOALS_FILE()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(goals, null, 2) + '\n', { mode: 0o600 });
await fs.rename(tmp, GOALS_FILE());
}

export const iterateCmd = new Command('iterate')
.description('Observe metrics, have an agent propose changes, ship, measure. Powered by Claude / Codex / Qwen.')
.option('--from <input>', 'existing live url, repo, or local path to start observing + iterating on')
Expand Down Expand Up @@ -56,13 +79,58 @@ iterateCmd
.command('goals')
.description('Declare the success metrics iterate steers toward')
.argument('[kv...]', 'e.g. conversion=8% cpi=2.00 churn=5%')
.action((kv: string[]) => {
.option('--clear', 'remove all saved goals')
.option('--unset <key>', 'remove a single goal by key')
.option('--json', 'machine-readable output')
.action(async (kv: string[], opts: { clear?: boolean; unset?: string; json?: boolean }) => {
const goals = await loadGoals();

if (opts.clear) {
await saveGoals({});
console.log(kleur.yellow('all goals cleared'));
return;
}

if (opts.unset) {
if (opts.unset in goals) {
delete goals[opts.unset];
await saveGoals(goals);
console.log(kleur.yellow(`unset: ${opts.unset}`));
} else {
console.log(kleur.dim(`goal "${opts.unset}" not set`));
}
return;
}

if (kv.length === 0) {
console.log(kleur.dim('[stub] iterate goals — list current goals'));
if (Object.keys(goals).length === 0) {
console.log(kleur.dim('no goals set — pass key=value pairs to set them'));
return;
}
if (opts.json) {
console.log(JSON.stringify(goals, null, 2));
return;
}
console.log(kleur.bold('current goals:'));
for (const [k, v] of Object.entries(goals)) {
console.log(` ${kleur.cyan(k)} = ${v}`);
}
return;
}
console.log(kleur.cyan(`[stub] iterate goals set ${kv.join(' ')}`));
// TODO: persist goals; iterate run uses these as the optimization target

for (const pair of kv) {
const idx = pair.indexOf('=');
if (idx === -1) {
console.error(kleur.red(`invalid goal "${pair}" — expected key=value`));
continue;
}
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (!key) { console.error(kleur.red(`empty key in "${pair}"`)); continue; }
goals[key] = value;
console.log(kleur.green(` set ${key} = ${value}`));
}
await saveGoals(goals);
});

iterateCmd
Expand Down
103 changes: 100 additions & 3 deletions packages/cli/src/commands/promote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { Command } from 'commander';
import kleur from 'kleur';
import prompts from 'prompts';
import { runSetup, type AdapterWithSetup } from '@profullstack/sh1pt-core';
import {
runSetup,
type AdapterWithSetup,
type SocialPlatform,
type SocialPost,
getAdapterConfig,
} from '@profullstack/sh1pt-core';
import { describeInput, resolveInput } from '../input.js';
import { merchCmd } from './merch.js';
import { shipCmd as shipSub } from './ship.js';
Expand Down Expand Up @@ -265,6 +271,13 @@ function stripSocialPrefix(p: string): string {
return p.replace(/^social-/, '').toLowerCase();
}

function inferMediaKind(file: string): 'image' | 'video' | 'gif' {
const lower = file.toLowerCase();
if (lower.endsWith('.gif')) return 'gif';
if (/\.(mp4|mov|avi|webm|mkv)$/.test(lower)) return 'video';
return 'image';
}

socialCmd
.command('post')
.description('Cross-post to every connected platform with per-platform adaptation')
Expand All @@ -276,8 +289,92 @@ socialCmd
.option('--platform <id...>', 'subset; default: all connected')
.option('--schedule <iso>', 'publish at ISO timestamp; omit for now')
.option('--dry-run')
.action((opts) => {
console.log(kleur.green(`[stub] social post ${JSON.stringify(opts)}`));
.action(async (opts: {
body: string;
title?: string;
hashtags?: string;
media?: string[];
link?: string;
platform?: string[];
schedule?: string;
dryRun?: boolean;
}) => {
const post: SocialPost = {
body: opts.body,
title: opts.title,
hashtags: opts.hashtags ? opts.hashtags.split(',').map((h) => h.trim()).filter(Boolean) : undefined,
media: opts.media?.map((file) => ({ file, kind: inferMediaKind(file) })),
link: opts.link,
schedule: opts.schedule ? new Date(opts.schedule) : undefined,
};

const names = (opts.platform ?? SOCIAL_PLATFORMS).map(stripSocialPrefix).filter(Boolean);

if (opts.dryRun) {
console.log(kleur.cyan('dry-run: social post preview\n'));
for (const name of names) {
const pkg = `@profullstack/sh1pt-social-${name}`;
let adapter: SocialPlatform<unknown> | null = null;
try {
adapter = await loadInstalledPackage<SocialPlatform<unknown>>(pkg);
} catch {
// not installed — skip
}
if (!adapter) {
console.log(kleur.dim(` ${name}: not installed — run: sh1pt promote social setup --platform ${name}`));
continue;
}
const max = adapter.requires?.maxBodyChars;
const truncated = max && post.body.length > max ? post.body.slice(0, max - 3) + '...' : post.body;
console.log(kleur.bold(` ${adapter.label ?? name}`));
console.log(` body (${truncated.length} chars): ${truncated.slice(0, 80)}${truncated.length > 80 ? '…' : ''}`);
if (post.hashtags?.length) console.log(` hashtags: ${post.hashtags.map((h) => `#${h}`).join(' ')}`);
if (post.link) console.log(` link: ${post.link}`);
if (post.schedule) console.log(` schedule: ${post.schedule.toISOString()}`);
}
return;
}

let anyPosted = false;
for (const name of names) {
const pkg = `@profullstack/sh1pt-social-${name}`;
let adapter: SocialPlatform<unknown> | null = null;
try {
adapter = await loadInstalledPackage<SocialPlatform<unknown>>(pkg);
} catch {
// not installed
}
if (!adapter) {
console.log(kleur.dim(` ${name}: not installed — skipping`));
continue;
}

const adapterConfig = await getAdapterConfig(adapter.id);
if (!adapterConfig) {
console.log(kleur.yellow(` ${name}: not configured — run: sh1pt promote social setup --platform ${name}`));
continue;
}

const ctx = {
secret: (k: string) => process.env[k],
log: (m: string) => console.log(kleur.dim(` [${name}] ${m}`)),
dryRun: false,
};

try {
console.log(kleur.bold(` posting to ${adapter.label ?? name}…`));
await adapter.connect(ctx, adapterConfig);
const result = await adapter.post(ctx, post, adapterConfig);
console.log(kleur.green(` ✓ ${adapter.label ?? name} · ${result.url}`));
anyPosted = true;
} catch (err) {
console.error(kleur.red(` ✗ ${name}: ${err instanceof Error ? err.message : String(err)}`));
}
}

if (!anyPosted) {
console.log(kleur.yellow('\nno platforms posted — set up accounts with: sh1pt promote social setup'));
}
});

socialCmd
Expand Down
Loading