@jclvsh/dropspace 2.10.0 → 2.11.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @jclvsh/dropspace
2
2
 
3
- MCP server and CLI for the [Dropspace](https://www.dropspace.dev) API. Lets AI agents (Claude Code, Cursor) and humans manage launches, personas, media generation, boosts, autopilot, recommendations, platform connections, API keys, and webhooks. 59 tools across 13 categories.
3
+ MCP server and CLI for the [Dropspace](https://www.dropspace.dev) API. Lets AI agents (Claude Code, Cursor) and humans manage launches, personas, media generation, boosts, autopilot, recommendations, reddit engagement, platform connections, API keys, and webhooks. 69 tools across 14 categories.
4
4
 
5
5
  ## Installation
6
6
 
@@ -49,6 +49,7 @@ dropspace launches --help
49
49
  | media | generate, job | 2 |
50
50
  | connections | list | 1 |
51
51
  | boosts | create, list, get, activate, cancel, performance | 6 |
52
+ | reddit | suggest, config, configure, discover, comments, approve, reject, standing, activate, deactivate | 10 |
52
53
  | credits | balance, purchase | 2 |
53
54
  | keys | me, list, create, rename, revoke, rotate | 6 |
54
55
  | webhooks | list, create, get, update, delete, test, rotate-secret, deliveries | 8 |
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { registerUsageCommands } from "./commands/usage.js";
14
14
  import { registerCreditCommands } from "./commands/credits.js";
15
15
  import { registerMediaCommands } from "./commands/media.js";
16
16
  import { registerBoostCommands } from "./commands/boosts.js";
17
+ import { registerRedditCommands } from "./commands/reddit.js";
17
18
  import { registerAutopilotCommands } from "./commands/autopilot.js";
18
19
  import { registerRecommendationCommands } from "./commands/recommendations.js";
19
20
  const program = new Command()
@@ -36,6 +37,7 @@ try {
36
37
  registerBoostCommands(program, client);
37
38
  registerAutopilotCommands(program, client);
38
39
  registerRecommendationCommands(program, client);
40
+ registerRedditCommands(program, client);
39
41
  await program.parseAsync(process.argv);
40
42
  }
41
43
  catch (error) {
package/dist/client.d.ts CHANGED
@@ -4,6 +4,7 @@ export declare class DropspaceClient {
4
4
  constructor();
5
5
  get<T>(path: string, params?: Record<string, string>): Promise<T>;
6
6
  post<T>(path: string, body?: unknown): Promise<T>;
7
+ put<T>(path: string, body: unknown): Promise<T>;
7
8
  patch<T>(path: string, body: unknown): Promise<T>;
8
9
  delete(path: string): Promise<void>;
9
10
  private request;
package/dist/client.js CHANGED
@@ -31,6 +31,13 @@ export class DropspaceClient {
31
31
  body: body !== undefined ? JSON.stringify(body) : undefined,
32
32
  });
33
33
  }
34
+ async put(path, body) {
35
+ return this.request(`${this.baseUrl}${path}`, {
36
+ method: "PUT",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+ }
34
41
  async patch(path, body) {
35
42
  return this.request(`${this.baseUrl}${path}`, {
36
43
  method: "PATCH",
@@ -0,0 +1,8 @@
1
+ import type { Command } from "commander";
2
+ import type { DropspaceClient } from "../client.js";
3
+ /**
4
+ * Reddit discovery + engagement CLI — the earn-standing-before-promoting loop:
5
+ * suggest → activate → discover → approve/reject → standing. Requires an api key whose
6
+ * account has reddit connected; caps and gates are enforced server-side (hard rails).
7
+ */
8
+ export declare function registerRedditCommands(program: Command, client: DropspaceClient): void;
@@ -0,0 +1,146 @@
1
+ import { formatOutput, handleError, getFormat } from "../cli-utils.js";
2
+ /**
3
+ * Reddit discovery + engagement CLI — the earn-standing-before-promoting loop:
4
+ * suggest → activate → discover → approve/reject → standing. Requires an api key whose
5
+ * account has reddit connected; caps and gates are enforced server-side (hard rails).
6
+ */
7
+ export function registerRedditCommands(program, client) {
8
+ const reddit = program.command("reddit").description("subreddit discovery + engagement (earn standing before promoting)");
9
+ reddit
10
+ .command("suggest")
11
+ .description("ICP-matched subreddit discovery with per-community standing badges")
12
+ .requiredOption("-b, --brief <text>", "what the product is and who it's for (10-2000 chars)")
13
+ .action(async (opts) => {
14
+ try {
15
+ const result = await client.post("/reddit/subreddits/suggest", { brief: opts.brief });
16
+ formatOutput(result, getFormat(program.opts()));
17
+ }
18
+ catch (error) {
19
+ handleError(error);
20
+ }
21
+ });
22
+ reddit
23
+ .command("config")
24
+ .description("read the engagement settings (enabled, caps, gate state, comment voice)")
25
+ .action(async () => {
26
+ try {
27
+ const result = await client.get("/reddit/engagement/config");
28
+ formatOutput(result, getFormat(program.opts()));
29
+ }
30
+ catch (error) {
31
+ handleError(error);
32
+ }
33
+ });
34
+ reddit
35
+ .command("configure")
36
+ .description("enable/disable engagement and tune caps (values above the rails are clamped)")
37
+ .requiredOption("--enabled <bool>", "true | false")
38
+ .option("--max-per-day <n>", "daily comment cap (clamped to 5)")
39
+ .option("--max-per-sub <n>", "per-subreddit daily cap (clamped to 2)")
40
+ .option("--min-gap <minutes>", "minimum gap between comments (floored at 20)")
41
+ .option("--persona-id <uuid|null>", "persona whose voice drafts the comments; 'null' = auto (latest)")
42
+ .action(async (opts) => {
43
+ try {
44
+ const body = { enabled: opts.enabled === "true" };
45
+ if (opts.maxPerDay)
46
+ body.max_comments_per_day = parseInt(opts.maxPerDay, 10);
47
+ if (opts.maxPerSub)
48
+ body.max_comments_per_sub_per_day = parseInt(opts.maxPerSub, 10);
49
+ if (opts.minGap)
50
+ body.min_gap_minutes = parseInt(opts.minGap, 10);
51
+ if (opts.personaId)
52
+ body.persona_id = opts.personaId === "null" ? null : opts.personaId;
53
+ const result = await client.put("/reddit/engagement/config", body);
54
+ formatOutput(result, getFormat(program.opts()));
55
+ }
56
+ catch (error) {
57
+ handleError(error);
58
+ }
59
+ });
60
+ reddit
61
+ .command("discover")
62
+ .description("draft helpful comments for one active subreddit (posts nothing — fills the review queue)")
63
+ .action(async () => {
64
+ try {
65
+ const result = await client.post("/reddit/engagement/discover");
66
+ formatOutput(result, getFormat(program.opts()));
67
+ }
68
+ catch (error) {
69
+ handleError(error);
70
+ }
71
+ });
72
+ reddit
73
+ .command("comments")
74
+ .description("list drafted/posted engagement comments")
75
+ .option("--status <status>", "pending (review queue, default) | history")
76
+ .action(async (opts) => {
77
+ try {
78
+ const result = await client.get("/reddit/engagement/comments", opts.status ? { status: opts.status } : undefined);
79
+ formatOutput(result, getFormat(program.opts()));
80
+ }
81
+ catch (error) {
82
+ handleError(error);
83
+ }
84
+ });
85
+ reddit
86
+ .command("approve <comment-id>")
87
+ .description("approve a drafted comment — POSTS it into a pacing slot (caps/gaps still enforced)")
88
+ .option("--body <text>", "edited comment text (replaces the draft)")
89
+ .action(async (commentId, opts) => {
90
+ try {
91
+ const result = await client.post(`/reddit/engagement/comments/${commentId}/approve`, opts.body ? { body: opts.body } : undefined);
92
+ formatOutput(result, getFormat(program.opts()));
93
+ }
94
+ catch (error) {
95
+ handleError(error);
96
+ }
97
+ });
98
+ reddit
99
+ .command("reject <comment-id>")
100
+ .description("discard a drafted comment without posting")
101
+ .action(async (commentId) => {
102
+ try {
103
+ const result = await client.post(`/reddit/engagement/comments/${commentId}/reject`);
104
+ formatOutput(result, getFormat(program.opts()));
105
+ }
106
+ catch (error) {
107
+ handleError(error);
108
+ }
109
+ });
110
+ reddit
111
+ .command("standing")
112
+ .description("earned standing per subreddit (tier, comments, karma, any removal pause)")
113
+ .action(async () => {
114
+ try {
115
+ const result = await client.get("/reddit/engagement/standing");
116
+ formatOutput(result, getFormat(program.opts()));
117
+ }
118
+ catch (error) {
119
+ handleError(error);
120
+ }
121
+ });
122
+ reddit
123
+ .command("activate <subreddit>")
124
+ .description("activate a subreddit as an engagement candidate (you pick the communities)")
125
+ .action(async (subreddit) => {
126
+ try {
127
+ const result = await client.post("/reddit/engagement/subreddits", { subreddit });
128
+ formatOutput(result, getFormat(program.opts()));
129
+ }
130
+ catch (error) {
131
+ handleError(error);
132
+ }
133
+ });
134
+ reddit
135
+ .command("deactivate <subreddit>")
136
+ .description("stop drafting comments for a subreddit (earned standing is kept)")
137
+ .action(async (subreddit) => {
138
+ try {
139
+ await client.delete(`/reddit/engagement/subreddits/${encodeURIComponent(subreddit)}`);
140
+ formatOutput({ subreddit, engagement_enabled: false }, getFormat(program.opts()));
141
+ }
142
+ catch (error) {
143
+ handleError(error);
144
+ }
145
+ });
146
+ }
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { registerMediaTools } from "./tools/media.js";
14
14
  import { registerBoostTools } from "./tools/boosts.js";
15
15
  import { registerAutopilotTools } from "./tools/autopilot.js";
16
16
  import { registerRecommendationTools } from "./tools/recommendations.js";
17
+ import { registerRedditTools } from "./tools/reddit.js";
17
18
  const server = new McpServer({
18
19
  name: "dropspace",
19
20
  version: "2.9.0",
@@ -31,5 +32,6 @@ registerMediaTools(server, client);
31
32
  registerBoostTools(server, client);
32
33
  registerAutopilotTools(server, client);
33
34
  registerRecommendationTools(server, client);
35
+ registerRedditTools(server, client);
34
36
  const transport = new StdioServerTransport();
35
37
  await server.connect(transport);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Reddit engagement + discovery MCP tools.
3
+ *
4
+ * The full loop an agent runs to earn standing before promoting (reddit's 90:10 norm):
5
+ * suggest ICP subreddits → activate the relevant ones → discover (drafts helpful comments) →
6
+ * approve/reject within the hard rails → standing grows → launch fan-out posts clear the
7
+ * publish-time 90:10 gate. Requires an api key whose account has reddit connected.
8
+ */
9
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { DropspaceClient } from "../client.js";
11
+ export declare function registerRedditTools(server: McpServer, client: DropspaceClient): void;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Reddit engagement + discovery MCP tools.
3
+ *
4
+ * The full loop an agent runs to earn standing before promoting (reddit's 90:10 norm):
5
+ * suggest ICP subreddits → activate the relevant ones → discover (drafts helpful comments) →
6
+ * approve/reject within the hard rails → standing grows → launch fan-out posts clear the
7
+ * publish-time 90:10 gate. Requires an api key whose account has reddit connected.
8
+ */
9
+ import { z } from "zod";
10
+ const subredditParam = z
11
+ .string()
12
+ .min(3)
13
+ .describe("community name, no r/ prefix (canonicalized to lowercase server-side)");
14
+ const commentIdParam = z.string().uuid().describe("engagement comment ID (from list_engagement_comments)");
15
+ function ok(result) {
16
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
17
+ }
18
+ function fail(error) {
19
+ return {
20
+ content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
21
+ isError: true,
22
+ };
23
+ }
24
+ export function registerRedditTools(server, client) {
25
+ server.tool("suggest_subreddits", "ICP-matched subreddit discovery: pass a product brief, get verified real subreddits with a promo-policy classification and a green/yellow/red standing badge for THIS account. prefer green subs; earn standing in yellow ones by commenting before posting.", {
26
+ brief: z.string().min(10).max(2000).describe("what the product is and who it's for"),
27
+ }, async (args) => {
28
+ try {
29
+ return ok(await client.post("/reddit/subreddits/suggest", args));
30
+ }
31
+ catch (error) {
32
+ return fail(error);
33
+ }
34
+ });
35
+ server.tool("get_engagement_config", "read the account's reddit engagement settings — enabled, caps, the 14d/50-karma gate state, and which persona voices the comments.", {}, async () => {
36
+ try {
37
+ return ok(await client.get("/reddit/engagement/config"));
38
+ }
39
+ catch (error) {
40
+ return fail(error);
41
+ }
42
+ });
43
+ server.tool("update_engagement_config", "enable/disable reddit engagement and tune caps. caps are STRUCTURAL RAILS: values above the ceilings (5/day, 2/sub/day, 20-min gap) are clamped, not obeyed. persona_id pins which persona voices the comments (null = auto latest).", {
44
+ enabled: z.boolean().describe("turn engagement on/off for this account"),
45
+ max_comments_per_day: z.number().int().min(1).max(5).optional().describe("clamped to 5"),
46
+ max_comments_per_sub_per_day: z.number().int().min(1).max(2).optional().describe("clamped to 2"),
47
+ min_gap_minutes: z.number().int().min(20).optional().describe("floored at 20"),
48
+ persona_id: z
49
+ .string()
50
+ .uuid()
51
+ .nullable()
52
+ .optional()
53
+ .describe("persona whose voice drafts the comments; null = auto (latest complete persona)"),
54
+ }, async (args) => {
55
+ try {
56
+ return ok(await client.put("/reddit/engagement/config", args));
57
+ }
58
+ catch (error) {
59
+ return fail(error);
60
+ }
61
+ });
62
+ server.tool("discover_engagement", "draft helpful comments for ONE of the account's active subreddits (call again to cover more). fills the review queue; posts NOTHING — approval is the separate, rail-gated step. `skipped` names why nothing ran (kill_switch | not_enabled | gate_not_passed).", {}, async () => {
63
+ try {
64
+ return ok(await client.post("/reddit/engagement/discover"));
65
+ }
66
+ catch (error) {
67
+ return fail(error);
68
+ }
69
+ });
70
+ server.tool("list_engagement_comments", "list drafted/posted engagement comments. status=pending (default) is the review queue awaiting approve/reject; status=history is everything already acted on.", {
71
+ status: z.enum(["pending", "history"]).optional().describe("default: pending"),
72
+ }, async (args) => {
73
+ try {
74
+ return ok(await client.get("/reddit/engagement/comments", args.status ? { status: args.status } : undefined));
75
+ }
76
+ catch (error) {
77
+ return fail(error);
78
+ }
79
+ });
80
+ server.tool("approve_engagement_comment", "approve a drafted comment — this POSTS it (scheduled into a pacing slot). the rails still apply: per-day/per-sub caps and the min-gap return 429 when hit. optional body edits the draft before posting.", {
81
+ comment_id: commentIdParam,
82
+ body: z.string().min(20).max(1500).optional().describe("edited comment text (replaces the draft)"),
83
+ }, async (args) => {
84
+ try {
85
+ return ok(await client.post(`/reddit/engagement/comments/${args.comment_id}/approve`, args.body ? { body: args.body } : undefined));
86
+ }
87
+ catch (error) {
88
+ return fail(error);
89
+ }
90
+ });
91
+ server.tool("reject_engagement_comment", "discard a drafted comment without posting.", { comment_id: commentIdParam }, async (args) => {
92
+ try {
93
+ return ok(await client.post(`/reddit/engagement/comments/${args.comment_id}/reject`));
94
+ }
95
+ catch (error) {
96
+ return fail(error);
97
+ }
98
+ });
99
+ server.tool("get_engagement_standing", "the standing the account has EARNED per subreddit — tier (none → seedling → active → established), comments posted, karma earned, and any active removal pause. this is what lets fan-out posts clear the 90:10 gate.", {}, async () => {
100
+ try {
101
+ return ok(await client.get("/reddit/engagement/standing"));
102
+ }
103
+ catch (error) {
104
+ return fail(error);
105
+ }
106
+ });
107
+ server.tool("activate_engagement_subreddit", "activate a subreddit as an engagement candidate — the seed that lets a fresh account draft its first comments. pick subs from suggest_subreddits; YOU choose the communities, the machine only sustains engagement within them.", { subreddit: subredditParam }, async (args) => {
108
+ try {
109
+ return ok(await client.post("/reddit/engagement/subreddits", { subreddit: args.subreddit }));
110
+ }
111
+ catch (error) {
112
+ return fail(error);
113
+ }
114
+ });
115
+ server.tool("deactivate_engagement_subreddit", "stop drafting comments for a subreddit. earned standing is kept, so re-activating later resumes where it left off.", { subreddit: subredditParam }, async (args) => {
116
+ try {
117
+ await client.delete(`/reddit/engagement/subreddits/${encodeURIComponent(args.subreddit)}`);
118
+ return ok({ subreddit: args.subreddit, engagement_enabled: false });
119
+ }
120
+ catch (error) {
121
+ return fail(error);
122
+ }
123
+ });
124
+ }
package/dist/types.d.ts CHANGED
@@ -423,3 +423,69 @@ export interface RecommendationApplyResponse {
423
423
  recommendation: Recommendation;
424
424
  outcome: RecommendationApplyOutcome;
425
425
  }
426
+ export interface SubredditStandingBadge {
427
+ level: string;
428
+ tier: string;
429
+ promo_posts_here: number;
430
+ contributions_here: number;
431
+ promo_share: number | null;
432
+ reasons: string[];
433
+ }
434
+ export interface SubredditSuggestion {
435
+ name: string;
436
+ title: string;
437
+ subscribers: number;
438
+ over18: boolean;
439
+ subreddit_type: string;
440
+ promo_policy: string;
441
+ promo_evidence: string | null;
442
+ standing: SubredditStandingBadge;
443
+ reason: string;
444
+ }
445
+ export interface SubredditSuggestions {
446
+ icp_summary: string;
447
+ suggestions: SubredditSuggestion[];
448
+ }
449
+ export interface EngagementConfig {
450
+ enabled: boolean;
451
+ max_comments_per_day: number;
452
+ max_comments_per_sub_per_day: number;
453
+ min_gap_minutes: number;
454
+ gate_passed: boolean;
455
+ account_username: string | null;
456
+ account_total_karma: number | null;
457
+ persona_id: string | null;
458
+ }
459
+ export interface EngagementComment {
460
+ id: string;
461
+ comment_kind: string;
462
+ subreddit: string;
463
+ body: string;
464
+ status: string;
465
+ scheduled_for: string | null;
466
+ comment_permalink: string | null;
467
+ comment_score: number | null;
468
+ error_message: string | null;
469
+ created_at: string;
470
+ target: {
471
+ subreddit: string;
472
+ thread_title: string;
473
+ thread_url: string;
474
+ thread_author: string | null;
475
+ relevance_score: number | null;
476
+ } | null;
477
+ }
478
+ export interface SubredditStanding {
479
+ subreddit: string;
480
+ tier: string;
481
+ comments_posted: number;
482
+ comments_removed: number;
483
+ karma_earned: number;
484
+ paused_until: string | null;
485
+ pause_reason: string | null;
486
+ last_activity_at: string | null;
487
+ }
488
+ export interface EngagementSweepResult {
489
+ drafted: number;
490
+ skipped: string | null;
491
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jclvsh/dropspace",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "MCP server and CLI for the Dropspace API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",