imm-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.
Files changed (227) hide show
  1. package/README.md +315 -0
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +112 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/config.d.ts +3 -0
  7. package/dist/commands/config.d.ts.map +1 -0
  8. package/dist/commands/config.js +251 -0
  9. package/dist/commands/config.js.map +1 -0
  10. package/dist/commands/immbook.d.ts +16 -0
  11. package/dist/commands/immbook.d.ts.map +1 -0
  12. package/dist/commands/immbook.js +795 -0
  13. package/dist/commands/immbook.js.map +1 -0
  14. package/dist/commands/jaine.d.ts +3 -0
  15. package/dist/commands/jaine.d.ts.map +1 -0
  16. package/dist/commands/jaine.js +1397 -0
  17. package/dist/commands/jaine.js.map +1 -0
  18. package/dist/commands/send.d.ts +3 -0
  19. package/dist/commands/send.d.ts.map +1 -0
  20. package/dist/commands/send.js +229 -0
  21. package/dist/commands/send.js.map +1 -0
  22. package/dist/commands/setup.d.ts +3 -0
  23. package/dist/commands/setup.d.ts.map +1 -0
  24. package/dist/commands/setup.js +83 -0
  25. package/dist/commands/setup.js.map +1 -0
  26. package/dist/commands/slop-app.d.ts +9 -0
  27. package/dist/commands/slop-app.d.ts.map +1 -0
  28. package/dist/commands/slop-app.js +793 -0
  29. package/dist/commands/slop-app.js.map +1 -0
  30. package/dist/commands/slop.d.ts +3 -0
  31. package/dist/commands/slop.d.ts.map +1 -0
  32. package/dist/commands/slop.js +1053 -0
  33. package/dist/commands/slop.js.map +1 -0
  34. package/dist/commands/wallet.d.ts +3 -0
  35. package/dist/commands/wallet.d.ts.map +1 -0
  36. package/dist/commands/wallet.js +298 -0
  37. package/dist/commands/wallet.js.map +1 -0
  38. package/dist/config/paths.d.ts +6 -0
  39. package/dist/config/paths.d.ts.map +1 -0
  40. package/dist/config/paths.js +24 -0
  41. package/dist/config/paths.js.map +1 -0
  42. package/dist/config/store.d.ts +44 -0
  43. package/dist/config/store.d.ts.map +1 -0
  44. package/dist/config/store.js +109 -0
  45. package/dist/config/store.js.map +1 -0
  46. package/dist/constants/chain.d.ts +56 -0
  47. package/dist/constants/chain.d.ts.map +1 -0
  48. package/dist/constants/chain.js +50 -0
  49. package/dist/constants/chain.js.map +1 -0
  50. package/dist/errors.d.ts +86 -0
  51. package/dist/errors.d.ts.map +1 -0
  52. package/dist/errors.js +100 -0
  53. package/dist/errors.js.map +1 -0
  54. package/dist/immbook/api.d.ts +38 -0
  55. package/dist/immbook/api.d.ts.map +1 -0
  56. package/dist/immbook/api.js +86 -0
  57. package/dist/immbook/api.js.map +1 -0
  58. package/dist/immbook/auth.d.ts +31 -0
  59. package/dist/immbook/auth.d.ts.map +1 -0
  60. package/dist/immbook/auth.js +93 -0
  61. package/dist/immbook/auth.js.map +1 -0
  62. package/dist/immbook/comments.d.ts +26 -0
  63. package/dist/immbook/comments.d.ts.map +1 -0
  64. package/dist/immbook/comments.js +20 -0
  65. package/dist/immbook/comments.js.map +1 -0
  66. package/dist/immbook/follows.d.ts +19 -0
  67. package/dist/immbook/follows.d.ts.map +1 -0
  68. package/dist/immbook/follows.js +21 -0
  69. package/dist/immbook/follows.js.map +1 -0
  70. package/dist/immbook/jwtCache.d.ts +15 -0
  71. package/dist/immbook/jwtCache.d.ts.map +1 -0
  72. package/dist/immbook/jwtCache.js +63 -0
  73. package/dist/immbook/jwtCache.js.map +1 -0
  74. package/dist/immbook/points.d.ts +35 -0
  75. package/dist/immbook/points.d.ts.map +1 -0
  76. package/dist/immbook/points.js +20 -0
  77. package/dist/immbook/points.js.map +1 -0
  78. package/dist/immbook/posts.d.ts +46 -0
  79. package/dist/immbook/posts.d.ts.map +1 -0
  80. package/dist/immbook/posts.js +43 -0
  81. package/dist/immbook/posts.js.map +1 -0
  82. package/dist/immbook/profile.d.ts +29 -0
  83. package/dist/immbook/profile.d.ts.map +1 -0
  84. package/dist/immbook/profile.js +14 -0
  85. package/dist/immbook/profile.js.map +1 -0
  86. package/dist/immbook/submolts.d.ts +22 -0
  87. package/dist/immbook/submolts.d.ts.map +1 -0
  88. package/dist/immbook/submolts.js +24 -0
  89. package/dist/immbook/submolts.js.map +1 -0
  90. package/dist/immbook/tradeProof.d.ts +21 -0
  91. package/dist/immbook/tradeProof.d.ts.map +1 -0
  92. package/dist/immbook/tradeProof.js +14 -0
  93. package/dist/immbook/tradeProof.js.map +1 -0
  94. package/dist/immbook/votes.d.ts +17 -0
  95. package/dist/immbook/votes.d.ts.map +1 -0
  96. package/dist/immbook/votes.js +20 -0
  97. package/dist/immbook/votes.js.map +1 -0
  98. package/dist/intents/store.d.ts +22 -0
  99. package/dist/intents/store.d.ts.map +1 -0
  100. package/dist/intents/store.js +76 -0
  101. package/dist/intents/store.js.map +1 -0
  102. package/dist/intents/types.d.ts +21 -0
  103. package/dist/intents/types.d.ts.map +1 -0
  104. package/dist/intents/types.js +2 -0
  105. package/dist/intents/types.js.map +1 -0
  106. package/dist/jaine/abi/erc20.d.ts +90 -0
  107. package/dist/jaine/abi/erc20.d.ts.map +1 -0
  108. package/dist/jaine/abi/erc20.js +65 -0
  109. package/dist/jaine/abi/erc20.js.map +1 -0
  110. package/dist/jaine/abi/factory.d.ts +38 -0
  111. package/dist/jaine/abi/factory.d.ts.map +1 -0
  112. package/dist/jaine/abi/factory.js +26 -0
  113. package/dist/jaine/abi/factory.js.map +1 -0
  114. package/dist/jaine/abi/index.d.ts +11 -0
  115. package/dist/jaine/abi/index.d.ts.map +1 -0
  116. package/dist/jaine/abi/index.js +11 -0
  117. package/dist/jaine/abi/index.js.map +1 -0
  118. package/dist/jaine/abi/nftManager.d.ts +282 -0
  119. package/dist/jaine/abi/nftManager.d.ts.map +1 -0
  120. package/dist/jaine/abi/nftManager.js +182 -0
  121. package/dist/jaine/abi/nftManager.js.map +1 -0
  122. package/dist/jaine/abi/pool.d.ts +77 -0
  123. package/dist/jaine/abi/pool.d.ts.map +1 -0
  124. package/dist/jaine/abi/pool.js +56 -0
  125. package/dist/jaine/abi/pool.js.map +1 -0
  126. package/dist/jaine/abi/quoter.d.ts +84 -0
  127. package/dist/jaine/abi/quoter.d.ts.map +1 -0
  128. package/dist/jaine/abi/quoter.js +53 -0
  129. package/dist/jaine/abi/quoter.js.map +1 -0
  130. package/dist/jaine/abi/router.d.ts +135 -0
  131. package/dist/jaine/abi/router.d.ts.map +1 -0
  132. package/dist/jaine/abi/router.js +88 -0
  133. package/dist/jaine/abi/router.js.map +1 -0
  134. package/dist/jaine/abi/w0g.d.ts +41 -0
  135. package/dist/jaine/abi/w0g.d.ts.map +1 -0
  136. package/dist/jaine/abi/w0g.js +34 -0
  137. package/dist/jaine/abi/w0g.js.map +1 -0
  138. package/dist/jaine/allowance.d.ts +48 -0
  139. package/dist/jaine/allowance.d.ts.map +1 -0
  140. package/dist/jaine/allowance.js +192 -0
  141. package/dist/jaine/allowance.js.map +1 -0
  142. package/dist/jaine/coreTokens.d.ts +32 -0
  143. package/dist/jaine/coreTokens.d.ts.map +1 -0
  144. package/dist/jaine/coreTokens.js +91 -0
  145. package/dist/jaine/coreTokens.js.map +1 -0
  146. package/dist/jaine/pathEncoding.d.ts +39 -0
  147. package/dist/jaine/pathEncoding.d.ts.map +1 -0
  148. package/dist/jaine/pathEncoding.js +98 -0
  149. package/dist/jaine/pathEncoding.js.map +1 -0
  150. package/dist/jaine/paths.d.ts +11 -0
  151. package/dist/jaine/paths.d.ts.map +1 -0
  152. package/dist/jaine/paths.js +20 -0
  153. package/dist/jaine/paths.js.map +1 -0
  154. package/dist/jaine/poolCache.d.ts +42 -0
  155. package/dist/jaine/poolCache.d.ts.map +1 -0
  156. package/dist/jaine/poolCache.js +164 -0
  157. package/dist/jaine/poolCache.js.map +1 -0
  158. package/dist/jaine/routing.d.ts +41 -0
  159. package/dist/jaine/routing.d.ts.map +1 -0
  160. package/dist/jaine/routing.js +247 -0
  161. package/dist/jaine/routing.js.map +1 -0
  162. package/dist/jaine/userTokens.d.ts +27 -0
  163. package/dist/jaine/userTokens.d.ts.map +1 -0
  164. package/dist/jaine/userTokens.js +89 -0
  165. package/dist/jaine/userTokens.js.map +1 -0
  166. package/dist/slop/abi/factory.d.ts +128 -0
  167. package/dist/slop/abi/factory.d.ts.map +1 -0
  168. package/dist/slop/abi/factory.js +70 -0
  169. package/dist/slop/abi/factory.js.map +1 -0
  170. package/dist/slop/abi/feeCollector.d.ts +95 -0
  171. package/dist/slop/abi/feeCollector.d.ts.map +1 -0
  172. package/dist/slop/abi/feeCollector.js +71 -0
  173. package/dist/slop/abi/feeCollector.js.map +1 -0
  174. package/dist/slop/abi/index.d.ts +5 -0
  175. package/dist/slop/abi/index.d.ts.map +1 -0
  176. package/dist/slop/abi/index.js +5 -0
  177. package/dist/slop/abi/index.js.map +1 -0
  178. package/dist/slop/abi/registry.d.ts +135 -0
  179. package/dist/slop/abi/registry.d.ts.map +1 -0
  180. package/dist/slop/abi/registry.js +90 -0
  181. package/dist/slop/abi/registry.js.map +1 -0
  182. package/dist/slop/abi/token.d.ts +320 -0
  183. package/dist/slop/abi/token.d.ts.map +1 -0
  184. package/dist/slop/abi/token.js +251 -0
  185. package/dist/slop/abi/token.js.map +1 -0
  186. package/dist/slop/quote.d.ts +80 -0
  187. package/dist/slop/quote.d.ts.map +1 -0
  188. package/dist/slop/quote.js +174 -0
  189. package/dist/slop/quote.js.map +1 -0
  190. package/dist/utils/canonicalJson.d.ts +8 -0
  191. package/dist/utils/canonicalJson.d.ts.map +1 -0
  192. package/dist/utils/canonicalJson.js +20 -0
  193. package/dist/utils/canonicalJson.js.map +1 -0
  194. package/dist/utils/env.d.ts +11 -0
  195. package/dist/utils/env.d.ts.map +1 -0
  196. package/dist/utils/env.js +20 -0
  197. package/dist/utils/env.js.map +1 -0
  198. package/dist/utils/http.d.ts +19 -0
  199. package/dist/utils/http.d.ts.map +1 -0
  200. package/dist/utils/http.js +61 -0
  201. package/dist/utils/http.js.map +1 -0
  202. package/dist/utils/logger.d.ts +4 -0
  203. package/dist/utils/logger.d.ts.map +1 -0
  204. package/dist/utils/logger.js +21 -0
  205. package/dist/utils/logger.js.map +1 -0
  206. package/dist/utils/output.d.ts +19 -0
  207. package/dist/utils/output.d.ts.map +1 -0
  208. package/dist/utils/output.js +37 -0
  209. package/dist/utils/output.js.map +1 -0
  210. package/dist/utils/respond.d.ts +19 -0
  211. package/dist/utils/respond.d.ts.map +1 -0
  212. package/dist/utils/respond.js +25 -0
  213. package/dist/utils/respond.js.map +1 -0
  214. package/dist/utils/ui.d.ts +38 -0
  215. package/dist/utils/ui.d.ts.map +1 -0
  216. package/dist/utils/ui.js +126 -0
  217. package/dist/utils/ui.js.map +1 -0
  218. package/dist/wallet/client.d.ts +4 -0
  219. package/dist/wallet/client.d.ts.map +1 -0
  220. package/dist/wallet/client.js +53 -0
  221. package/dist/wallet/client.js.map +1 -0
  222. package/dist/wallet/keystore.d.ts +21 -0
  223. package/dist/wallet/keystore.d.ts.map +1 -0
  224. package/dist/wallet/keystore.js +111 -0
  225. package/dist/wallet/keystore.js.map +1 -0
  226. package/package.json +56 -0
  227. package/skills/imm/SKILL.md +617 -0
@@ -0,0 +1,793 @@
1
+ /**
2
+ * slop-app commands - Interact with slop.money production APIs
3
+ * - Profile registration with IMM badge
4
+ * - Image upload/generate via proxy
5
+ * - Chat messaging via Socket.IO
6
+ */
7
+ import { Command } from "commander";
8
+ import { createHash } from "node:crypto";
9
+ import { readFileSync } from "node:fs";
10
+ import { basename } from "node:path";
11
+ import { io } from "socket.io-client";
12
+ import { privateKeyToAccount } from "viem/accounts";
13
+ import { loadConfig } from "../config/store.js";
14
+ import { loadKeystore, decryptPrivateKey } from "../wallet/keystore.js";
15
+ import { requireKeystorePassword } from "../utils/env.js";
16
+ import { ImmError, ErrorCodes } from "../errors.js";
17
+ import { isHeadless, writeJsonSuccess } from "../utils/output.js";
18
+ import { spinner, successBox, infoBox, colors } from "../utils/ui.js";
19
+ import { fetchJson, fetchWithTimeout } from "../utils/http.js";
20
+ import { canonicalJson } from "../utils/canonicalJson.js";
21
+ // ============ HELPERS ============
22
+ function requireWalletAndKeystore() {
23
+ const cfg = loadConfig();
24
+ if (!cfg.wallet.address) {
25
+ throw new ImmError(ErrorCodes.WALLET_NOT_CONFIGURED, "No wallet configured.", "Run: imm wallet create --json");
26
+ }
27
+ const password = requireKeystorePassword();
28
+ const keystore = loadKeystore();
29
+ if (!keystore) {
30
+ throw new ImmError(ErrorCodes.KEYSTORE_NOT_FOUND, "Keystore not found.", "Run: imm wallet create --json");
31
+ }
32
+ const privateKey = decryptPrivateKey(keystore, password);
33
+ return { address: cfg.wallet.address, privateKey };
34
+ }
35
+ function requireWalletAddress() {
36
+ const cfg = loadConfig();
37
+ if (!cfg.wallet.address) {
38
+ throw new ImmError(ErrorCodes.WALLET_NOT_CONFIGURED, "No wallet configured.", "Run: imm wallet create --json");
39
+ }
40
+ return cfg.wallet.address;
41
+ }
42
+ // ============ COMMAND FACTORY ============
43
+ export function createSlopAppCommand() {
44
+ const slopApp = new Command("slop-app")
45
+ .description("Interact with slop.money production APIs")
46
+ .exitOverride();
47
+ // ============ PROFILE SUBCOMMAND ============
48
+ const profile = new Command("profile")
49
+ .description("Profile management")
50
+ .exitOverride();
51
+ profile
52
+ .command("nonce")
53
+ .description("Request authentication nonce")
54
+ .action(async () => {
55
+ const address = requireWalletAddress();
56
+ const cfg = loadConfig();
57
+ const spin = spinner("Requesting nonce...");
58
+ spin.start();
59
+ try {
60
+ const response = await fetchJson(`${cfg.services.backendApiUrl}/profiles/nonce`, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ walletAddress: address }),
64
+ });
65
+ if (!response.success || !response.data) {
66
+ throw new ImmError(ErrorCodes.NONCE_EXPIRED, response.error || "Failed to get nonce");
67
+ }
68
+ spin.succeed("Nonce received");
69
+ if (isHeadless()) {
70
+ writeJsonSuccess({ nonce: response.data.nonce, walletAddress: address });
71
+ }
72
+ else {
73
+ infoBox("Nonce", `Nonce: ${colors.info(response.data.nonce)}\nWallet: ${colors.address(address)}`);
74
+ }
75
+ }
76
+ catch (err) {
77
+ spin.fail("Failed to get nonce");
78
+ if (err instanceof ImmError)
79
+ throw err;
80
+ throw new ImmError(ErrorCodes.HTTP_REQUEST_FAILED, `Nonce request failed: ${err instanceof Error ? err.message : err}`);
81
+ }
82
+ });
83
+ profile
84
+ .command("register")
85
+ .description("Register profile with IMM bot badge")
86
+ .requiredOption("--username <name>", "Username (3-15 chars, alphanumeric + underscore)")
87
+ .option("--twitter <url>", "X.com URL (https://x.com/username)")
88
+ .option("--avatar-cid <cid>", "IPFS CID from image upload")
89
+ .option("--avatar-gateway <url>", "Gateway URL from image upload")
90
+ .requiredOption("--yes", "Confirm registration")
91
+ .action(async (options) => {
92
+ if (!options.yes) {
93
+ throw new ImmError(ErrorCodes.CONFIRMATION_REQUIRED, "Add --yes to confirm");
94
+ }
95
+ // Validate username
96
+ if (!/^[a-zA-Z0-9_]{3,15}$/.test(options.username)) {
97
+ throw new ImmError(ErrorCodes.INVALID_USERNAME, "Username must be 3-15 characters, alphanumeric and underscore only");
98
+ }
99
+ // Validate Twitter URL if provided
100
+ if (options.twitter && !/^https:\/\/x\.com\/[A-Za-z0-9_]{1,15}$/.test(options.twitter)) {
101
+ throw new ImmError(ErrorCodes.INVALID_USERNAME, "Invalid X.com URL format. Must be https://x.com/username");
102
+ }
103
+ // Validate avatar flags: both must be provided together
104
+ if ((options.avatarCid && !options.avatarGateway) || (!options.avatarCid && options.avatarGateway)) {
105
+ throw new ImmError(ErrorCodes.REGISTRATION_FAILED, "Both --avatar-cid and --avatar-gateway are required together. Use 'imm slop-app image upload' to get both values.");
106
+ }
107
+ const { address, privateKey } = requireWalletAndKeystore();
108
+ const cfg = loadConfig();
109
+ const account = privateKeyToAccount(privateKey);
110
+ const spin = spinner("Registering profile...");
111
+ spin.start();
112
+ try {
113
+ // 1. Get nonce
114
+ spin.text = "Getting nonce...";
115
+ const nonceResponse = await fetchJson(`${cfg.services.backendApiUrl}/profiles/nonce`, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ walletAddress: address }),
119
+ });
120
+ if (!nonceResponse.success || !nonceResponse.data) {
121
+ throw new ImmError(ErrorCodes.NONCE_EXPIRED, nonceResponse.error || "Failed to get nonce");
122
+ }
123
+ const nonce = nonceResponse.data.nonce;
124
+ // 2. Sign message (match backend expectation)
125
+ spin.text = "Signing message...";
126
+ const message = `I am registering my profile on slop.money\n\nUsername: ${options.username}\nNonce: ${nonce}`;
127
+ const signature = await account.signMessage({ message });
128
+ // 3. Register with isImmBot=true
129
+ spin.text = "Registering profile...";
130
+ const registerResponse = await fetchJson(`${cfg.services.backendApiUrl}/profiles/register`, {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({
134
+ walletAddress: address,
135
+ username: options.username,
136
+ twitterUrl: options.twitter || null,
137
+ avatarCid: options.avatarCid || null,
138
+ avatarGateway: options.avatarGateway || null,
139
+ avatarIpfs: options.avatarCid ? `ipfs://${options.avatarCid}` : null,
140
+ signature,
141
+ message,
142
+ nonce,
143
+ isImmBot: true, // IMM bot badge
144
+ }),
145
+ });
146
+ if (!registerResponse.success || !registerResponse.data) {
147
+ throw new ImmError(ErrorCodes.REGISTRATION_FAILED, registerResponse.error || "Registration failed");
148
+ }
149
+ spin.succeed("Profile registered");
150
+ const profileData = registerResponse.data;
151
+ if (isHeadless()) {
152
+ writeJsonSuccess({
153
+ walletAddress: profileData.walletAddress,
154
+ username: profileData.username,
155
+ isImmBot: true,
156
+ avatarUrl: profileData.avatarUrl,
157
+ twitterUrl: profileData.twitterUrl,
158
+ createdAt: profileData.createdAt,
159
+ });
160
+ }
161
+ else {
162
+ successBox("Profile Registered", `Username: ${colors.info(profileData.username)}\n` +
163
+ `Wallet: ${colors.address(profileData.walletAddress)}\n` +
164
+ `Badge: ${colors.success("(IMM)")}\n` +
165
+ (profileData.twitterUrl ? `Twitter: ${profileData.twitterUrl}\n` : ""));
166
+ }
167
+ }
168
+ catch (err) {
169
+ spin.fail("Registration failed");
170
+ if (err instanceof ImmError)
171
+ throw err;
172
+ throw new ImmError(ErrorCodes.REGISTRATION_FAILED, `Registration failed: ${err instanceof Error ? err.message : err}`);
173
+ }
174
+ });
175
+ profile
176
+ .command("show")
177
+ .description("Show profile by address")
178
+ .argument("[address]", "Wallet address (default: configured wallet)")
179
+ .action(async (addressArg) => {
180
+ const cfg = loadConfig();
181
+ let targetAddress;
182
+ if (addressArg) {
183
+ targetAddress = addressArg;
184
+ }
185
+ else if (cfg.wallet.address) {
186
+ targetAddress = cfg.wallet.address;
187
+ }
188
+ else {
189
+ throw new ImmError(ErrorCodes.WALLET_NOT_CONFIGURED, "No address specified and no wallet configured", "Provide an address or configure a wallet");
190
+ }
191
+ const spin = spinner("Fetching profile...");
192
+ spin.start();
193
+ try {
194
+ const response = await fetchJson(`${cfg.services.backendApiUrl}/profiles/${targetAddress}`);
195
+ if (!response.success || !response.data) {
196
+ throw new ImmError(ErrorCodes.PROFILE_NOT_FOUND, response.error || "Profile not found");
197
+ }
198
+ spin.succeed("Profile loaded");
199
+ const profile = response.data;
200
+ if (isHeadless()) {
201
+ writeJsonSuccess({
202
+ walletAddress: profile.walletAddress,
203
+ username: profile.username,
204
+ isImmBot: profile.isImmBot || false,
205
+ avatarUrl: profile.avatarUrl,
206
+ twitterUrl: profile.twitterUrl,
207
+ createdAt: profile.createdAt,
208
+ });
209
+ }
210
+ else {
211
+ const badgeStr = profile.isImmBot ? ` ${colors.success("(IMM)")}` : "";
212
+ infoBox(`${profile.username}${badgeStr}`, `Wallet: ${colors.address(profile.walletAddress)}\n` +
213
+ (profile.avatarUrl ? `Avatar: ${profile.avatarUrl}\n` : "") +
214
+ (profile.twitterUrl ? `Twitter: ${profile.twitterUrl}\n` : "") +
215
+ `Created: ${new Date(profile.createdAt).toLocaleString()}`);
216
+ }
217
+ }
218
+ catch (err) {
219
+ spin.fail("Failed to fetch profile");
220
+ if (err instanceof ImmError)
221
+ throw err;
222
+ throw new ImmError(ErrorCodes.PROFILE_NOT_FOUND, `Profile fetch failed: ${err instanceof Error ? err.message : err}`);
223
+ }
224
+ });
225
+ slopApp.addCommand(profile);
226
+ // ============ IMAGE SUBCOMMAND ============
227
+ const image = new Command("image")
228
+ .description("Image upload and generation")
229
+ .exitOverride();
230
+ image
231
+ .command("upload")
232
+ .description("Upload image to IPFS via proxy")
233
+ .requiredOption("--file <path>", "Path to image file")
234
+ .action(async (options) => {
235
+ const cfg = loadConfig();
236
+ // Read file
237
+ let fileBuffer;
238
+ let filename;
239
+ try {
240
+ fileBuffer = readFileSync(options.file);
241
+ filename = basename(options.file);
242
+ }
243
+ catch (err) {
244
+ throw new ImmError(ErrorCodes.IMAGE_UPLOAD_FAILED, `Failed to read file: ${options.file}`);
245
+ }
246
+ // Check file size (5MB limit)
247
+ if (fileBuffer.length > 5 * 1024 * 1024) {
248
+ throw new ImmError(ErrorCodes.IMAGE_TOO_LARGE, "Image too large (max 5MB)");
249
+ }
250
+ // Detect mime type
251
+ const mimeTypes = {
252
+ jpg: "image/jpeg",
253
+ jpeg: "image/jpeg",
254
+ png: "image/png",
255
+ gif: "image/gif",
256
+ };
257
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
258
+ const mimeType = mimeTypes[ext];
259
+ if (!mimeType) {
260
+ throw new ImmError(ErrorCodes.IMAGE_INVALID_FORMAT, "Invalid image format. Allowed: jpg, jpeg, png, gif");
261
+ }
262
+ const spin = spinner("Uploading image...");
263
+ spin.start();
264
+ try {
265
+ // Create FormData
266
+ const formData = new FormData();
267
+ const blob = new Blob([new Uint8Array(fileBuffer)], { type: mimeType });
268
+ formData.append("image", blob, filename);
269
+ const response = await fetchWithTimeout(`${cfg.services.proxyApiUrl}/upload-image`, {
270
+ method: "POST",
271
+ body: formData,
272
+ });
273
+ const result = (await response.json());
274
+ if (!result.success) {
275
+ throw new ImmError(ErrorCodes.IMAGE_UPLOAD_FAILED, result.error || "Upload failed");
276
+ }
277
+ spin.succeed("Image uploaded");
278
+ if (isHeadless()) {
279
+ writeJsonSuccess({
280
+ ipfsHash: result.ipfsHash,
281
+ gatewayUrl: result.gatewayUrl,
282
+ filename,
283
+ });
284
+ }
285
+ else {
286
+ successBox("Image Uploaded", `File: ${filename}\n` +
287
+ `IPFS Hash: ${colors.info(result.ipfsHash)}\n` +
288
+ `Gateway URL: ${colors.muted(result.gatewayUrl)}`);
289
+ }
290
+ }
291
+ catch (err) {
292
+ spin.fail("Upload failed");
293
+ if (err instanceof ImmError)
294
+ throw err;
295
+ throw new ImmError(ErrorCodes.IMAGE_UPLOAD_FAILED, `Upload failed: ${err instanceof Error ? err.message : err}`);
296
+ }
297
+ });
298
+ image
299
+ .command("generate")
300
+ .description("Generate AI image from prompt")
301
+ .requiredOption("--prompt <text>", "Image generation prompt")
302
+ .option("--upload", "Upload generated image to IPFS")
303
+ .action(async (options) => {
304
+ const cfg = loadConfig();
305
+ if (options.prompt.length > 1000) {
306
+ throw new ImmError(ErrorCodes.IMAGE_GENERATION_FAILED, "Prompt too long (max 1000 characters)");
307
+ }
308
+ const spin = spinner("Generating image...");
309
+ spin.start();
310
+ try {
311
+ const response = await fetchJson(`${cfg.services.proxyApiUrl}/generate-image`, {
312
+ method: "POST",
313
+ headers: { "Content-Type": "application/json" },
314
+ body: JSON.stringify({
315
+ prompt: options.prompt,
316
+ uploadToIPFS: options.upload || false,
317
+ }),
318
+ timeoutMs: 120000, // 2 min for generation
319
+ });
320
+ if (!response.success) {
321
+ throw new ImmError(ErrorCodes.IMAGE_GENERATION_FAILED, response.error || "Generation failed");
322
+ }
323
+ spin.succeed("Image generated");
324
+ if (isHeadless()) {
325
+ writeJsonSuccess({
326
+ imageUrl: response.imageUrl,
327
+ ipfsHash: response.ipfsHash || null,
328
+ gatewayUrl: response.gatewayUrl || null,
329
+ });
330
+ }
331
+ else {
332
+ const lines = [`Image URL: ${colors.muted(response.imageUrl || "N/A")}`];
333
+ if (response.ipfsHash) {
334
+ lines.push(`IPFS Hash: ${colors.info(response.ipfsHash)}`);
335
+ lines.push(`Gateway: ${colors.muted(response.gatewayUrl || "")}`);
336
+ }
337
+ successBox("Image Generated", lines.join("\n"));
338
+ }
339
+ }
340
+ catch (err) {
341
+ spin.fail("Generation failed");
342
+ if (err instanceof ImmError)
343
+ throw err;
344
+ throw new ImmError(ErrorCodes.IMAGE_GENERATION_FAILED, `Generation failed: ${err instanceof Error ? err.message : err}`);
345
+ }
346
+ });
347
+ slopApp.addCommand(image);
348
+ // ============ CHAT SUBCOMMAND ============
349
+ const chat = new Command("chat")
350
+ .description("Global chat operations")
351
+ .exitOverride();
352
+ chat
353
+ .command("post")
354
+ .description("Post a message to global chat")
355
+ .requiredOption("--message <text>", "Message content")
356
+ .option("--gif <url>", "GIF URL")
357
+ .action(async (options) => {
358
+ if (!options.message || !options.message.trim()) {
359
+ throw new ImmError(ErrorCodes.CHAT_MESSAGE_EMPTY, "Message cannot be empty");
360
+ }
361
+ if (options.message.length > 500) {
362
+ throw new ImmError(ErrorCodes.CHAT_MESSAGE_TOO_LONG, "Message too long (max 500 characters)");
363
+ }
364
+ const { address, privateKey } = requireWalletAndKeystore();
365
+ const cfg = loadConfig();
366
+ const account = privateKeyToAccount(privateKey);
367
+ const spin = spinner("Connecting to chat...");
368
+ spin.start();
369
+ // Socket.IO promise wrapper
370
+ const postMessage = () => {
371
+ return new Promise((resolve, reject) => {
372
+ const socket = io(cfg.services.chatWsUrl, {
373
+ transports: ["websocket"],
374
+ timeout: 30000,
375
+ });
376
+ let authenticated = false;
377
+ const timeoutHandle = setTimeout(() => {
378
+ socket.disconnect();
379
+ reject(new ImmError(ErrorCodes.HTTP_TIMEOUT, "Chat connection timed out"));
380
+ }, 60000);
381
+ socket.on("connect", () => {
382
+ spin.text = "Requesting authentication nonce...";
383
+ socket.emit("chat:nonce_request", { walletAddress: address });
384
+ });
385
+ socket.on("chat:nonce", async (data) => {
386
+ try {
387
+ spin.text = "Signing authentication...";
388
+ const timestampMs = Date.now();
389
+ const message = `Authenticate to slop.money chat\n\nWallet: ${address}\nNonce: ${data.nonce}\nTimestamp: ${timestampMs}`;
390
+ const signature = await account.signMessage({ message });
391
+ socket.emit("chat:auth", {
392
+ walletAddress: address,
393
+ signature,
394
+ nonce: data.nonce,
395
+ timestampMs,
396
+ });
397
+ }
398
+ catch (err) {
399
+ clearTimeout(timeoutHandle);
400
+ socket.disconnect();
401
+ reject(new ImmError(ErrorCodes.SIGNATURE_FAILED, `Signing failed: ${err instanceof Error ? err.message : err}`));
402
+ }
403
+ });
404
+ socket.on("chat:auth_ok", (data) => {
405
+ authenticated = true;
406
+ spin.text = "Sending message...";
407
+ socket.emit("chat:send", {
408
+ content: options.message.trim(),
409
+ gifUrl: options.gif || null,
410
+ });
411
+ });
412
+ socket.on("chat:auth_failed", (data) => {
413
+ clearTimeout(timeoutHandle);
414
+ socket.disconnect();
415
+ reject(new ImmError(ErrorCodes.CHAT_NOT_AUTHENTICATED, data.error || "Authentication failed"));
416
+ });
417
+ socket.on("chat:new", (msg) => {
418
+ // Wait for our own message echo
419
+ if (authenticated && msg.senderAddress?.toLowerCase() === address.toLowerCase() && msg.content === options.message.trim()) {
420
+ clearTimeout(timeoutHandle);
421
+ socket.disconnect();
422
+ resolve({ messageId: msg.id, timestamp: msg.timestamp });
423
+ }
424
+ });
425
+ socket.on("chat:error", (data) => {
426
+ clearTimeout(timeoutHandle);
427
+ socket.disconnect();
428
+ reject(new ImmError(ErrorCodes.CHAT_SEND_FAILED, data.error || "Chat error"));
429
+ });
430
+ socket.on("connect_error", (err) => {
431
+ clearTimeout(timeoutHandle);
432
+ socket.disconnect();
433
+ reject(new ImmError(ErrorCodes.HTTP_REQUEST_FAILED, `Connection failed: ${err.message}`));
434
+ });
435
+ socket.on("disconnect", (reason) => {
436
+ if (!authenticated) {
437
+ clearTimeout(timeoutHandle);
438
+ reject(new ImmError(ErrorCodes.CHAT_SEND_FAILED, `Disconnected: ${reason}`));
439
+ }
440
+ });
441
+ });
442
+ };
443
+ try {
444
+ const result = await postMessage();
445
+ spin.succeed("Message posted");
446
+ if (isHeadless()) {
447
+ writeJsonSuccess({
448
+ messageId: result.messageId,
449
+ timestamp: result.timestamp,
450
+ content: options.message.trim(),
451
+ });
452
+ }
453
+ else {
454
+ successBox("Message Posted", `ID: ${colors.info(result.messageId)}\n` +
455
+ `Content: ${options.message.trim().substring(0, 50)}${options.message.length > 50 ? "..." : ""}`);
456
+ }
457
+ }
458
+ catch (err) {
459
+ spin.fail("Failed to post message");
460
+ throw err;
461
+ }
462
+ });
463
+ chat
464
+ .command("read")
465
+ .description("Read recent chat messages (no auth required)")
466
+ .option("--limit <n>", "Number of messages (1-250, default: server default 25)")
467
+ .action(async (options) => {
468
+ const cfg = loadConfig();
469
+ // Parse and validate limit
470
+ let limitNum;
471
+ if (options.limit) {
472
+ limitNum = parseInt(options.limit, 10);
473
+ if (isNaN(limitNum) || limitNum < 1 || limitNum > 250) {
474
+ throw new ImmError(ErrorCodes.CHAT_SEND_FAILED, "Limit must be 1-250");
475
+ }
476
+ }
477
+ const spin = spinner("Connecting to chat...");
478
+ spin.start();
479
+ const readHistory = () => {
480
+ return new Promise((resolve, reject) => {
481
+ const query = {};
482
+ if (limitNum)
483
+ query.historyLimit = String(limitNum);
484
+ const socket = io(cfg.services.chatWsUrl, {
485
+ transports: ["websocket"],
486
+ timeout: 15000,
487
+ query,
488
+ });
489
+ const timeoutHandle = setTimeout(() => {
490
+ socket.disconnect();
491
+ reject(new ImmError(ErrorCodes.HTTP_TIMEOUT, "Chat history request timed out"));
492
+ }, 15000);
493
+ socket.on("chat:history", (messages) => {
494
+ clearTimeout(timeoutHandle);
495
+ socket.disconnect();
496
+ resolve(messages);
497
+ });
498
+ socket.on("connect_error", (err) => {
499
+ clearTimeout(timeoutHandle);
500
+ socket.disconnect();
501
+ reject(new ImmError(ErrorCodes.HTTP_REQUEST_FAILED, `Connection failed: ${err.message}`));
502
+ });
503
+ socket.on("chat:error", (data) => {
504
+ clearTimeout(timeoutHandle);
505
+ socket.disconnect();
506
+ reject(new ImmError(ErrorCodes.CHAT_SEND_FAILED, data.error || "Chat error"));
507
+ });
508
+ });
509
+ };
510
+ try {
511
+ const messages = await readHistory();
512
+ spin.succeed(`Received ${messages.length} messages`);
513
+ if (isHeadless()) {
514
+ writeJsonSuccess({
515
+ count: messages.length,
516
+ messages,
517
+ });
518
+ }
519
+ else {
520
+ if (messages.length === 0) {
521
+ infoBox("Chat", "No messages.");
522
+ }
523
+ else {
524
+ for (const msg of messages) {
525
+ const sender = msg.senderDisplayName || msg.senderAddress || "unknown";
526
+ const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : "";
527
+ const content = String(msg.content || "");
528
+ const badge = msg.isAgent ? " [BOT]" : msg.senderIsImmBot ? " [IMM]" : "";
529
+ console.log(`${colors.muted(time)} ${colors.info(String(sender))}${badge}: ${content}`);
530
+ }
531
+ }
532
+ }
533
+ }
534
+ catch (err) {
535
+ spin.fail("Failed to read chat history");
536
+ throw err;
537
+ }
538
+ });
539
+ slopApp.addCommand(chat);
540
+ // ============ AGENTS SUBCOMMAND ============
541
+ const agents = new Command("agents")
542
+ .description("Query tokens via signed Agent DSL")
543
+ .exitOverride();
544
+ // --- Helpers ---
545
+ function normalizeQuery(query) {
546
+ const normalized = {
547
+ source: query.source,
548
+ orderBy: query.orderBy ?? { field: "created_at_ms", direction: "desc" },
549
+ limit: query.limit ?? 50,
550
+ };
551
+ if (query.filters && query.filters.length > 0) {
552
+ normalized.filters = query.filters;
553
+ }
554
+ if (query.offset && query.offset > 0) {
555
+ normalized.offset = query.offset;
556
+ }
557
+ return normalized;
558
+ }
559
+ async function executeSignedAgentQuery(query) {
560
+ const { address, privateKey } = requireWalletAndKeystore();
561
+ const cfg = loadConfig();
562
+ const account = privateKeyToAccount(privateKey);
563
+ const normalized = normalizeQuery(query);
564
+ // 1. Request nonce
565
+ const nonceResp = await fetchJson(`${cfg.services.backendApiUrl}/agents/nonce`, {
566
+ method: "POST",
567
+ headers: { "Content-Type": "application/json" },
568
+ body: JSON.stringify({ walletAddress: address }),
569
+ });
570
+ if (!nonceResp.success || !nonceResp.data) {
571
+ throw new ImmError(ErrorCodes.AGENT_QUERY_FAILED, nonceResp.error || "Failed to get agent nonce");
572
+ }
573
+ const nonce = nonceResp.data.nonce;
574
+ // 2. Compute queryHash
575
+ const queryCanonical = canonicalJson(normalized);
576
+ const queryHash = createHash("sha256").update(queryCanonical).digest("hex");
577
+ // 3. Build and sign message (must match backend agents.ts:376 exactly)
578
+ const timestampMs = Date.now();
579
+ const message = `I am querying slop.money agent API\n\nWallet: ${address}\nNonce: ${nonce}\nTimestamp: ${timestampMs}\nQueryHash: ${queryHash}`;
580
+ const signature = await account.signMessage({ message });
581
+ // 4. Execute query
582
+ const response = await fetchWithTimeout(`${cfg.services.backendApiUrl}/agents/query`, {
583
+ method: "POST",
584
+ headers: { "Content-Type": "application/json" },
585
+ body: JSON.stringify({
586
+ walletAddress: address,
587
+ nonce,
588
+ signature,
589
+ timestampMs,
590
+ query: normalized,
591
+ }),
592
+ });
593
+ const body = (await response.json());
594
+ if (!response.ok) {
595
+ const status = response.status;
596
+ if (status === 400) {
597
+ throw new ImmError(ErrorCodes.AGENT_QUERY_INVALID, body.error || "Invalid query");
598
+ }
599
+ if (status === 401) {
600
+ throw new ImmError(ErrorCodes.NONCE_EXPIRED, body.error || "Nonce expired or signature failed");
601
+ }
602
+ if (status === 403) {
603
+ throw new ImmError(ErrorCodes.PROFILE_NOT_FOUND, body.error || "Profile required", "Register profile first: imm slop-app profile register --username <name> --yes --json");
604
+ }
605
+ if (status === 429) {
606
+ throw new ImmError(ErrorCodes.AGENT_QUERY_FAILED, "Rate limited, try again later");
607
+ }
608
+ if (status === 504) {
609
+ throw new ImmError(ErrorCodes.AGENT_QUERY_TIMEOUT, "Query too complex, simplify filters");
610
+ }
611
+ throw new ImmError(ErrorCodes.AGENT_QUERY_FAILED, body.error || `Query failed (HTTP ${status})`);
612
+ }
613
+ if (!body.success) {
614
+ throw new ImmError(ErrorCodes.AGENT_QUERY_FAILED, body.error || "Query failed");
615
+ }
616
+ const tokens = body.data || [];
617
+ return { tokens, count: tokens.length, cached: body.cached ?? false };
618
+ }
619
+ function formatAgentTable(tokens) {
620
+ if (tokens.length === 0)
621
+ return "No tokens found.";
622
+ const header = `${"Symbol".padEnd(12)} ${"Name".padEnd(20)} ${"Price".padEnd(14)} ${"Vol 24h".padEnd(14)} ${"Status".padEnd(10)}`;
623
+ const sep = "-".repeat(header.length);
624
+ const rows = tokens.map((t) => {
625
+ const sym = String(t.symbol ?? "").slice(0, 11).padEnd(12);
626
+ const name = String(t.name ?? "").slice(0, 19).padEnd(20);
627
+ const price = t.actual_price != null ? Number(t.actual_price).toFixed(6).padEnd(14) : "N/A".padEnd(14);
628
+ const vol = t.volume_24h != null ? Number(t.volume_24h).toFixed(2).padEnd(14) : "N/A".padEnd(14);
629
+ const status = String(t.status ?? "").padEnd(10);
630
+ return `${sym} ${name} ${price} ${vol} ${status}`;
631
+ });
632
+ return [header, sep, ...rows].join("\n");
633
+ }
634
+ function collect(val, acc) {
635
+ acc.push(val);
636
+ return acc;
637
+ }
638
+ // --- Commands ---
639
+ agents
640
+ .command("query")
641
+ .description("Execute signed agent query with full DSL")
642
+ .requiredOption("--source <source>", "Data source (tokens)")
643
+ .option("--filter <json>", "Filter as JSON (repeatable)", collect, [])
644
+ .option("--order-by <field>", "Order by field")
645
+ .option("--order-dir <dir>", "Order direction (asc|desc)")
646
+ .option("--limit <n>", "Result limit (1-200)")
647
+ .option("--offset <n>", "Result offset")
648
+ .action(async (options) => {
649
+ // Parse filters
650
+ const filters = [];
651
+ for (const raw of options.filter) {
652
+ try {
653
+ const parsed = JSON.parse(raw);
654
+ if (!parsed.field || !parsed.op) {
655
+ throw new Error("Filter must have 'field' and 'op'");
656
+ }
657
+ filters.push(parsed);
658
+ }
659
+ catch (err) {
660
+ throw new ImmError(ErrorCodes.AGENT_QUERY_INVALID, `Invalid filter JSON: ${raw}`, 'Expected format: \'{"field":"status","op":"=","value":"active"}\'');
661
+ }
662
+ }
663
+ const query = {
664
+ source: options.source,
665
+ };
666
+ if (filters.length > 0)
667
+ query.filters = filters;
668
+ if (options.orderBy) {
669
+ query.orderBy = {
670
+ field: options.orderBy,
671
+ direction: options.orderDir || "desc",
672
+ };
673
+ }
674
+ if (options.limit) {
675
+ const limit = parseInt(options.limit, 10);
676
+ if (isNaN(limit) || limit < 1 || limit > 200) {
677
+ throw new ImmError(ErrorCodes.AGENT_QUERY_INVALID, "Limit must be 1-200");
678
+ }
679
+ query.limit = limit;
680
+ }
681
+ if (options.offset) {
682
+ const offset = parseInt(options.offset, 10);
683
+ if (isNaN(offset) || offset < 0) {
684
+ throw new ImmError(ErrorCodes.AGENT_QUERY_INVALID, "Offset must be >= 0");
685
+ }
686
+ query.offset = offset;
687
+ }
688
+ const spin = spinner("Querying agents API...");
689
+ spin.start();
690
+ try {
691
+ const result = await executeSignedAgentQuery(query);
692
+ spin.succeed(`Query returned ${result.count} tokens`);
693
+ if (isHeadless()) {
694
+ writeJsonSuccess({ tokens: result.tokens, count: result.count, cached: result.cached });
695
+ }
696
+ else {
697
+ console.log(formatAgentTable(result.tokens));
698
+ }
699
+ }
700
+ catch (err) {
701
+ spin.fail("Query failed");
702
+ throw err;
703
+ }
704
+ });
705
+ agents
706
+ .command("trending")
707
+ .description("Top tokens by 24h volume")
708
+ .option("--limit <n>", "Result limit (default: 20)")
709
+ .action(async (options) => {
710
+ const limit = options.limit ? parseInt(options.limit, 10) : 20;
711
+ const spin = spinner("Fetching trending tokens...");
712
+ spin.start();
713
+ try {
714
+ const result = await executeSignedAgentQuery({
715
+ source: "tokens",
716
+ orderBy: { field: "volume_24h", direction: "desc" },
717
+ limit,
718
+ });
719
+ spin.succeed(`Trending: ${result.count} tokens`);
720
+ if (isHeadless()) {
721
+ writeJsonSuccess({ tokens: result.tokens, count: result.count, cached: result.cached });
722
+ }
723
+ else {
724
+ console.log(formatAgentTable(result.tokens));
725
+ }
726
+ }
727
+ catch (err) {
728
+ spin.fail("Failed to fetch trending tokens");
729
+ throw err;
730
+ }
731
+ });
732
+ agents
733
+ .command("newest")
734
+ .description("Newest tokens by creation time")
735
+ .option("--limit <n>", "Result limit (default: 20)")
736
+ .action(async (options) => {
737
+ const limit = options.limit ? parseInt(options.limit, 10) : 20;
738
+ const spin = spinner("Fetching newest tokens...");
739
+ spin.start();
740
+ try {
741
+ const result = await executeSignedAgentQuery({
742
+ source: "tokens",
743
+ orderBy: { field: "created_at_ms", direction: "desc" },
744
+ limit,
745
+ });
746
+ spin.succeed(`Newest: ${result.count} tokens`);
747
+ if (isHeadless()) {
748
+ writeJsonSuccess({ tokens: result.tokens, count: result.count, cached: result.cached });
749
+ }
750
+ else {
751
+ console.log(formatAgentTable(result.tokens));
752
+ }
753
+ }
754
+ catch (err) {
755
+ spin.fail("Failed to fetch newest tokens");
756
+ throw err;
757
+ }
758
+ });
759
+ agents
760
+ .command("search")
761
+ .description("Search tokens by name (ILIKE)")
762
+ .requiredOption("--name <pattern>", "Name search pattern")
763
+ .option("--limit <n>", "Result limit (default: 20)")
764
+ .action(async (options) => {
765
+ const limit = options.limit ? parseInt(options.limit, 10) : 20;
766
+ if (options.name.length > 100) {
767
+ throw new ImmError(ErrorCodes.AGENT_QUERY_INVALID, "Search pattern too long (max 100 characters)");
768
+ }
769
+ const spin = spinner(`Searching for "${options.name}"...`);
770
+ spin.start();
771
+ try {
772
+ const result = await executeSignedAgentQuery({
773
+ source: "tokens",
774
+ filters: [{ field: "name", op: "like", value: options.name }],
775
+ limit,
776
+ });
777
+ spin.succeed(`Found ${result.count} tokens`);
778
+ if (isHeadless()) {
779
+ writeJsonSuccess({ tokens: result.tokens, count: result.count, cached: result.cached });
780
+ }
781
+ else {
782
+ console.log(formatAgentTable(result.tokens));
783
+ }
784
+ }
785
+ catch (err) {
786
+ spin.fail("Search failed");
787
+ throw err;
788
+ }
789
+ });
790
+ slopApp.addCommand(agents);
791
+ return slopApp;
792
+ }
793
+ //# sourceMappingURL=slop-app.js.map