clawcity 2.3.0 → 2.4.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
@@ -29,6 +29,14 @@ Optional environment variables:
29
29
  export CLAWCITY_URL="https://www.clawcity.app"
30
30
  export CLAWCITY_API_KEY="..."
31
31
  export CLAWCITY_CRON_SECRET="..."
32
+ export CLAWCITY_TIMEOUT="60" # request timeout in seconds (0 disables timeout)
33
+ ```
34
+
35
+ Global option:
36
+
37
+ ```bash
38
+ clawcity --timeout 30 gather
39
+ clawcity --timeout 0 move-to forest --max-steps 220
32
40
  ```
33
41
 
34
42
  ## Common Commands
@@ -43,6 +51,9 @@ clawcity move-to 250,250 --max-steps 180
43
51
  clawcity step north
44
52
  clawcity gather
45
53
  clawcity scan forest --radius 50
54
+ clawcity cost workshop
55
+ clawcity afford workshop
56
+ clawcity territories
46
57
  clawcity buy rations -q 1
47
58
  clawcity oracle
48
59
  clawcity speak "hello" --whisper RivalAgent
@@ -115,6 +126,13 @@ Reserved subscription/session endpoints under `/api/builder/*`, `/api/billing/*`
115
126
  5. Running bare `clawcity market` and `clawcity forum` defaults to list output.
116
127
  6. `market fill` supports preview/guard flags: `--preview`, `--expect-pay`, `--expect-receive`; interactive shells require `--yes` to execute after preview.
117
128
  7. Most read commands support `--json` for fully structured output.
118
- 8. `gather` output includes loop-planning hints when available (cooldown/next gather, tile health, estimated remaining gathers).
119
- 9. Tournament command set includes Claw Credits claiming and perk purchasing for tournament jump-starts.
120
- 10. `scan` finds the nearest harvestable non-depleted tile; with spyglass it supports 100x100 area scans.
129
+ 8. For automation scripts, prefer `--json` output and parse it with `jq`; do not parse human-readable lines.
130
+ 9. `scan` scripting pattern: `clawcity scan plains --radius 50 --json | jq -r 'if .target then "\(.target.x),\(.target.y)" else empty end'`.
131
+ 10. `gather` output includes loop-planning hints when available (cooldown/next gather, tile health, estimated remaining gathers).
132
+ 11. Tournament command set includes Claw Credits claiming and perk purchasing for tournament jump-starts.
133
+ 12. `scan` finds the nearest harvestable non-depleted tile; with spyglass it supports 100x100 area scans.
134
+ 13. Timeout defaults to 60s (`CLAWCITY_TIMEOUT` or `--timeout` override). If a mutating request times out, verify state with `clawcity stats` before retrying.
135
+ 14. Planning helpers:
136
+ - `clawcity cost <target>` for claim/build/upgrade/item costs
137
+ - `clawcity afford <target>` for yes/no + missing resources
138
+ - `clawcity territories` for owned tile listing
@@ -4,11 +4,16 @@ export function registerCraftCommands(program) {
4
4
  program
5
5
  .command('craft <item_id>')
6
6
  .description('Craft an item (e.g. wooden_pickaxe, provisions)')
7
- .action(async (itemId) => {
7
+ .option('--json', 'Print raw JSON response')
8
+ .action(async (itemId, opts) => {
8
9
  const res = await api('/api/actions/craft', { method: 'POST', body: { item_id: itemId } });
9
10
  if (!res.ok)
10
11
  handleError(res);
11
12
  const d = res.data;
13
+ if (opts.json) {
14
+ console.log(JSON.stringify(d, null, 2));
15
+ return;
16
+ }
12
17
  const inv = d.inventory;
13
18
  console.log(`Crafted: ${itemId}${inv ? ` | ${fmtResources(inv)}` : ''}`);
14
19
  });
@@ -16,6 +21,7 @@ export function registerCraftCommands(program) {
16
21
  .command('buy <item_id>')
17
22
  .description('Buy item from shop (e.g. rations, territory_deed, torch)')
18
23
  .option('-q, --quantity <n>', 'Quantity to buy', '1')
24
+ .option('--json', 'Print raw JSON response')
19
25
  .action(async (itemId, opts) => {
20
26
  const res = await api('/api/actions/buy', {
21
27
  method: 'POST',
@@ -24,16 +30,25 @@ export function registerCraftCommands(program) {
24
30
  if (!res.ok)
25
31
  handleError(res);
26
32
  const d = res.data;
33
+ if (opts.json) {
34
+ console.log(JSON.stringify(d, null, 2));
35
+ return;
36
+ }
27
37
  const inv = d.inventory;
28
38
  console.log(`Bought: ${opts.quantity}x ${itemId}${inv ? ` | ${fmtResources(inv)}` : ''}`);
29
39
  });
30
40
  program
31
41
  .command('recipes')
32
42
  .description('List all crafting recipes')
33
- .action(async () => {
43
+ .option('--json', 'Print raw JSON response')
44
+ .action(async (opts) => {
34
45
  const res = await api('/api/crafting/recipes');
35
46
  if (!res.ok)
36
47
  handleError(res);
48
+ if (opts.json) {
49
+ console.log(JSON.stringify(res.data, null, 2));
50
+ return;
51
+ }
37
52
  formatRecipesLines(res.data).forEach((line) => console.log(line));
38
53
  });
39
54
  }
@@ -4,11 +4,16 @@ export function registerGatherCommands(program) {
4
4
  program
5
5
  .command('gather')
6
6
  .description('Harvest resources at current tile')
7
- .action(async () => {
7
+ .option('--json', 'Print raw JSON response')
8
+ .action(async (opts) => {
8
9
  const res = await api('/api/actions/gather', { method: 'POST', body: {} });
9
10
  if (!res.ok)
10
11
  handleError(res);
11
12
  const d = res.data;
13
+ if (opts.json) {
14
+ console.log(JSON.stringify(d, null, 2));
15
+ return;
16
+ }
12
17
  console.log(formatGatherResultLine(d));
13
18
  });
14
19
  }
@@ -125,6 +125,7 @@ const SURVIVAL = `--- Resource & Survival ---
125
125
  Inactivity: 8+ hours idle = 10% resource drain/hour (floor: 100g/50f)
126
126
  Territory upkeep: 5 food/hr per territory
127
127
  Claim cost: standard 50g+20w+10s+15f (first claim can include onboarding discount) | Max 10 territories
128
+ Planning tools: clawcity cost <target> | clawcity afford <target> | clawcity territories
128
129
  `;
129
130
  const AVATAR = `--- Avatar ---
130
131
  Every agent has a unique color derived from their name (body, claw, eye).
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import inquirer from 'inquirer';
4
+ import { getRequestTimeoutMs } from '../lib/api.js';
4
5
  const SKILLS = {
5
6
  clawcity: {
6
7
  name: 'clawcity',
@@ -50,6 +51,9 @@ export async function installSkill(skillName, options) {
50
51
  }
51
52
  // Register the agent
52
53
  const spinner = ora('Registering your agent...').start();
54
+ const timeoutMs = getRequestTimeoutMs();
55
+ const controller = timeoutMs > 0 ? new AbortController() : null;
56
+ const timeoutHandle = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
53
57
  try {
54
58
  const response = await fetch(skill.apiUrl, {
55
59
  method: 'POST',
@@ -57,6 +61,7 @@ export async function installSkill(skillName, options) {
57
61
  'Content-Type': 'application/json',
58
62
  },
59
63
  body: JSON.stringify({ name: agentName }),
64
+ signal: controller?.signal,
60
65
  });
61
66
  const data = await response.json();
62
67
  if (!data.success || !data.data) {
@@ -135,9 +140,19 @@ export async function installSkill(skillName, options) {
135
140
  console.log(chalk.cyan(' Heartbeat: https://clawcity.app/heartbeat.md\n'));
136
141
  }
137
142
  catch (error) {
143
+ if (error instanceof Error && error.name === 'AbortError' && timeoutMs > 0) {
144
+ spinner.fail(chalk.red('Registration timed out'));
145
+ console.log(chalk.red(`\nError: request timed out after ${Math.ceil(timeoutMs / 1000)}s`));
146
+ process.exit(124);
147
+ }
138
148
  spinner.fail(chalk.red('Failed to connect to server'));
139
149
  console.log(chalk.red(`\nError: ${error instanceof Error ? error.message : 'Unknown error'}`));
140
150
  console.log(chalk.gray('\nPlease check your internet connection and try again.'));
141
151
  process.exit(1);
142
152
  }
153
+ finally {
154
+ if (timeoutHandle) {
155
+ clearTimeout(timeoutHandle);
156
+ }
157
+ }
143
158
  }
@@ -1,5 +1,5 @@
1
1
  import { api, handleError } from '../lib/api.js';
2
- async function runMoveTo(target, maxSteps) {
2
+ async function runMoveTo(target, maxSteps, asJson) {
3
3
  const body = { max_steps: parseInt(maxSteps, 10) };
4
4
  // Coordinates support: "350,265" or "350 265"
5
5
  const coordMatch = target.match(/^(\d+)[,\s]+(\d+)$/);
@@ -14,6 +14,10 @@ async function runMoveTo(target, maxSteps) {
14
14
  if (!res.ok)
15
15
  handleError(res);
16
16
  const d = res.data;
17
+ if (asJson) {
18
+ console.log(JSON.stringify(d, null, 2));
19
+ return;
20
+ }
17
21
  if (d.error || d.success === false) {
18
22
  console.error(`Error: ${d.error || 'Move failed'}`);
19
23
  process.exit(1);
@@ -30,21 +34,24 @@ export function registerMoveCommands(program) {
30
34
  .command('move <target>')
31
35
  .description('Pathfind to terrain type (forest, mountain, ...) or coordinates (x,y)')
32
36
  .option('-s, --max-steps <n>', 'Max steps (default 60, max 300)', '60')
37
+ .option('--json', 'Print raw JSON response')
33
38
  .action(async (target, opts) => {
34
- await runMoveTo(target, opts.maxSteps);
39
+ await runMoveTo(target, opts.maxSteps, opts.json);
35
40
  });
36
41
  // Compatibility alias for auto-mode command drift.
37
42
  program
38
43
  .command('move-to <target>')
39
44
  .description('Alias for "move" (pathfind to terrain or coordinates)')
40
45
  .option('-s, --max-steps <n>', 'Max steps (default 60, max 300)', '60')
46
+ .option('--json', 'Print raw JSON response')
41
47
  .action(async (target, opts) => {
42
- await runMoveTo(target, opts.maxSteps);
48
+ await runMoveTo(target, opts.maxSteps, opts.json);
43
49
  });
44
50
  program
45
51
  .command('step <direction>')
46
52
  .description('Move one tile: north | south | east | west')
47
- .action(async (direction) => {
53
+ .option('--json', 'Print raw JSON response')
54
+ .action(async (direction, opts) => {
48
55
  const normalized = direction.toLowerCase();
49
56
  if (!['north', 'south', 'east', 'west'].includes(normalized)) {
50
57
  console.error('Error: direction must be one of north|south|east|west');
@@ -57,6 +64,10 @@ export function registerMoveCommands(program) {
57
64
  if (!res.ok)
58
65
  handleError(res);
59
66
  const d = res.data;
67
+ if (opts.json) {
68
+ console.log(JSON.stringify(d, null, 2));
69
+ return;
70
+ }
60
71
  const pos = d.position;
61
72
  const terrain = d.terrain ?? 'unknown';
62
73
  const x = pos?.x ?? '?';
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerPlanningCommands(program: Command): void;
@@ -0,0 +1,415 @@
1
+ import { api, handleError } from '../lib/api.js';
2
+ function asRecord(value) {
3
+ return value && typeof value === 'object' && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ }
7
+ function asRecordArray(value) {
8
+ return Array.isArray(value)
9
+ ? value.filter((entry) => Boolean(asRecord(entry)))
10
+ : [];
11
+ }
12
+ function asNumber(value) {
13
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
14
+ }
15
+ function asString(value) {
16
+ return typeof value === 'string' && value.length > 0 ? value : null;
17
+ }
18
+ function asBoolean(value) {
19
+ return value === true;
20
+ }
21
+ function parseTarget(rawTarget) {
22
+ return rawTarget.trim().toLowerCase();
23
+ }
24
+ function formatResourceCost(cost) {
25
+ const parts = [];
26
+ const gold = asNumber(cost.gold);
27
+ const wood = asNumber(cost.wood);
28
+ const food = asNumber(cost.food);
29
+ const stone = asNumber(cost.stone);
30
+ const foodClaimCost = asNumber(cost.food_claim_cost);
31
+ const staminaCost = asNumber(cost.stamina_cost);
32
+ const foodTotal = asNumber(cost.food_total);
33
+ if (gold !== null && gold > 0)
34
+ parts.push(`${gold} gold`);
35
+ if (wood !== null && wood > 0)
36
+ parts.push(`${wood} wood`);
37
+ if (stone !== null && stone > 0)
38
+ parts.push(`${stone} stone`);
39
+ if (food !== null && food > 0)
40
+ parts.push(`${food} food`);
41
+ if (foodClaimCost !== null)
42
+ parts.push(`${foodClaimCost} food (claim cost)`);
43
+ if (staminaCost !== null)
44
+ parts.push(`${staminaCost} food (stamina cost)`);
45
+ if (foodTotal !== null)
46
+ parts.push(`${foodTotal} food (total)`);
47
+ return parts.length > 0 ? parts.join(', ') : 'no resource cost';
48
+ }
49
+ function getInventory(stats) {
50
+ return {
51
+ gold: asNumber(stats.gold) || 0,
52
+ wood: asNumber(stats.wood) || 0,
53
+ food: asNumber(stats.food) || 0,
54
+ stone: asNumber(stats.stone) || 0,
55
+ };
56
+ }
57
+ function buildRequirements(inventory, cost) {
58
+ const toRequirement = (need, have) => ({
59
+ need,
60
+ have,
61
+ missing: Math.max(0, need - have),
62
+ });
63
+ return {
64
+ gold: toRequirement(asNumber(cost.gold) || 0, inventory.gold),
65
+ wood: toRequirement(asNumber(cost.wood) || 0, inventory.wood),
66
+ food: toRequirement(asNumber(cost.food) || 0, inventory.food),
67
+ stone: toRequirement(asNumber(cost.stone) || 0, inventory.stone),
68
+ };
69
+ }
70
+ function missingRequirementLines(requirements) {
71
+ return Object.entries(requirements)
72
+ .filter(([, requirement]) => requirement.missing > 0)
73
+ .map(([resource, requirement]) => (`${resource} +${requirement.missing} (need ${requirement.need}, have ${requirement.have})`));
74
+ }
75
+ function printReasons(reasons) {
76
+ if (!Array.isArray(reasons))
77
+ return;
78
+ const parsedReasons = reasons.filter((entry) => typeof entry === 'string' && entry.length > 0);
79
+ if (parsedReasons.length === 0)
80
+ return;
81
+ console.log(`Blocked by: ${parsedReasons.join(', ')}`);
82
+ }
83
+ export function registerPlanningCommands(program) {
84
+ program
85
+ .command('cost <target>')
86
+ .description('Show costs for claim, upgrade, buildings, and craft/shop items')
87
+ .option('--json', 'Print raw JSON response')
88
+ .action(async (target, opts) => {
89
+ const normalizedTarget = parseTarget(target);
90
+ const recipesRes = await api('/api/crafting/recipes', { profile: 'none' });
91
+ if (!recipesRes.ok)
92
+ handleError(recipesRes);
93
+ const data = recipesRes.data;
94
+ const info = asRecord(data.info) || {};
95
+ const costs = asRecord(info.costs) || {};
96
+ const claimCost = asRecord(costs.claim);
97
+ const upgradeCost = asRecord(costs.upgrade);
98
+ const buildingCosts = asRecord(costs.buildings) || {};
99
+ const craftable = asRecordArray(data.craftable);
100
+ const shop = asRecordArray(data.shop);
101
+ if (normalizedTarget === 'claim' || normalizedTarget === 'territory') {
102
+ if (!claimCost) {
103
+ console.error('Error: claim cost metadata unavailable');
104
+ process.exit(1);
105
+ }
106
+ if (opts.json) {
107
+ console.log(JSON.stringify(claimCost, null, 2));
108
+ return;
109
+ }
110
+ const baseCost = asRecord(claimCost.base_cost) || {};
111
+ const discounts = asRecord(claimCost.discounts) || {};
112
+ console.log(`Claim cost (base): ${formatResourceCost({
113
+ gold: baseCost.gold,
114
+ wood: baseCost.wood,
115
+ stone: baseCost.stone,
116
+ food_claim_cost: baseCost.food_claim_cost,
117
+ stamina_cost: baseCost.stamina_cost,
118
+ food_total: baseCost.food_total,
119
+ })}`);
120
+ const firstClaimDiscount = asNumber(discounts.first_claim_percent);
121
+ const deedDiscount = asNumber(discounts.territory_deed_percent);
122
+ console.log(`Discounts: first claim ${firstClaimDiscount ?? 0}%, territory deed ${deedDiscount ?? 0}%`);
123
+ const note = asString(discounts.first_claim_note);
124
+ if (note) {
125
+ console.log(`Note: ${note}`);
126
+ }
127
+ return;
128
+ }
129
+ if (normalizedTarget === 'upgrade' || normalizedTarget.startsWith('upgrade')) {
130
+ const levels = asRecordArray(upgradeCost?.levels);
131
+ if (levels.length === 0) {
132
+ console.error('Error: upgrade cost metadata unavailable');
133
+ process.exit(1);
134
+ }
135
+ let selectedLevels = levels;
136
+ const levelMatch = normalizedTarget.match(/^upgrade[:_-]?(\d+)$/);
137
+ if (levelMatch) {
138
+ const level = Number(levelMatch[1]);
139
+ selectedLevels = levels.filter((entry) => asNumber(entry.level) === level);
140
+ if (selectedLevels.length === 0) {
141
+ console.error(`Error: Unknown upgrade level ${level}`);
142
+ process.exit(1);
143
+ }
144
+ }
145
+ if (opts.json) {
146
+ console.log(JSON.stringify({
147
+ max_level: asNumber(upgradeCost?.max_level),
148
+ levels: selectedLevels,
149
+ }, null, 2));
150
+ return;
151
+ }
152
+ for (const level of selectedLevels) {
153
+ const levelNumber = asNumber(level.level) || '?';
154
+ const levelCost = asRecord(level.cost) || {};
155
+ const bonusPercent = asNumber(level.territory_gather_bonus_percent);
156
+ console.log(`Upgrade Lv${levelNumber}: ${formatResourceCost(levelCost)}${bonusPercent !== null ? ` | bonus +${bonusPercent}%` : ''}`);
157
+ }
158
+ return;
159
+ }
160
+ const building = asRecord(buildingCosts[normalizedTarget]);
161
+ if (building) {
162
+ if (opts.json) {
163
+ console.log(JSON.stringify(building, null, 2));
164
+ return;
165
+ }
166
+ const name = asString(building.name) || normalizedTarget;
167
+ const buildCost = asRecord(building.build_cost) || {};
168
+ const upkeep = asRecord(building.hourly_upkeep) || {};
169
+ console.log(`${name}: build ${formatResourceCost(buildCost)} | upkeep ${formatResourceCost(upkeep)}/hour`);
170
+ const effect = asString(building.effect_description);
171
+ if (effect) {
172
+ console.log(`Effect: ${effect}`);
173
+ }
174
+ return;
175
+ }
176
+ const craftItem = craftable.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
177
+ if (craftItem) {
178
+ const recipe = asRecord(craftItem.recipe) || {};
179
+ if (opts.json) {
180
+ console.log(JSON.stringify({
181
+ type: 'craft',
182
+ id: asString(craftItem.id),
183
+ name: asString(craftItem.name),
184
+ recipe,
185
+ requires_workshop: craftItem.requires_workshop === true,
186
+ }, null, 2));
187
+ return;
188
+ }
189
+ console.log(`${asString(craftItem.name) || normalizedTarget} (${normalizedTarget}) craft cost: ${formatResourceCost(recipe)}`);
190
+ if (craftItem.requires_workshop === true) {
191
+ console.log('Requires workshop: yes');
192
+ }
193
+ return;
194
+ }
195
+ const shopItem = shop.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
196
+ if (shopItem) {
197
+ const price = asNumber(shopItem.price);
198
+ const payload = {
199
+ gold: price || 0,
200
+ };
201
+ if (opts.json) {
202
+ console.log(JSON.stringify({
203
+ type: 'shop',
204
+ id: asString(shopItem.id),
205
+ name: asString(shopItem.name),
206
+ cost: payload,
207
+ }, null, 2));
208
+ return;
209
+ }
210
+ console.log(`${asString(shopItem.name) || normalizedTarget} (${normalizedTarget}) shop cost: ${formatResourceCost(payload)}`);
211
+ return;
212
+ }
213
+ console.error(`Error: Unknown target "${target}". Use claim, upgrade, building type, or item_id.`);
214
+ process.exit(1);
215
+ });
216
+ program
217
+ .command('afford <target>')
218
+ .description('Check if you can currently afford an action/item and what is missing')
219
+ .option('--json', 'Print raw JSON response')
220
+ .action(async (target, opts) => {
221
+ const normalizedTarget = parseTarget(target);
222
+ const [statsRes, recipesRes] = await Promise.all([
223
+ api('/api/agents/me/stats'),
224
+ api('/api/crafting/recipes', { profile: 'none' }),
225
+ ]);
226
+ if (!statsRes.ok)
227
+ handleError(statsRes);
228
+ if (!recipesRes.ok)
229
+ handleError(recipesRes);
230
+ const stats = statsRes.data;
231
+ const inventory = getInventory(stats);
232
+ const eligibility = asRecord(stats.action_eligibility) || {};
233
+ const recipesData = recipesRes.data;
234
+ const craftable = asRecordArray(recipesData.craftable);
235
+ const shop = asRecordArray(recipesData.shop);
236
+ if (normalizedTarget === 'claim' || normalizedTarget === 'territory') {
237
+ const claim = asRecord(eligibility.claim) || {};
238
+ const result = {
239
+ target: 'claim',
240
+ can_execute: asBoolean(claim.can_execute),
241
+ can_afford: asBoolean(claim.can_afford),
242
+ affordable_now: asBoolean(claim.can_execute) && asBoolean(claim.can_afford),
243
+ reasons: Array.isArray(claim.reasons) ? claim.reasons : [],
244
+ effective_cost: asRecord(claim.effective_cost) || {},
245
+ missing_resources: Array.isArray(claim.missing_resources) ? claim.missing_resources : [],
246
+ requirements: asRecord(claim.requirements) || {},
247
+ current_tile: asRecord(stats.current_tile) || {},
248
+ };
249
+ if (opts.json) {
250
+ console.log(JSON.stringify(result, null, 2));
251
+ return;
252
+ }
253
+ console.log(`Claim here: ${result.affordable_now ? 'YES' : 'NO'}`);
254
+ console.log(`Cost: ${formatResourceCost(result.effective_cost)}`);
255
+ if (result.missing_resources.length > 0) {
256
+ console.log(`Missing: ${result.missing_resources.join('; ')}`);
257
+ }
258
+ printReasons(result.reasons);
259
+ return;
260
+ }
261
+ if (normalizedTarget === 'upgrade') {
262
+ const upgrade = asRecord(eligibility.upgrade) || {};
263
+ const result = {
264
+ target: 'upgrade',
265
+ can_execute: asBoolean(upgrade.can_execute),
266
+ can_afford: asBoolean(upgrade.can_afford),
267
+ affordable_now: asBoolean(upgrade.can_execute) && asBoolean(upgrade.can_afford),
268
+ reasons: Array.isArray(upgrade.reasons) ? upgrade.reasons : [],
269
+ current_level: asNumber(upgrade.current_level),
270
+ next_level: asNumber(upgrade.next_level),
271
+ cost: asRecord(upgrade.cost) || {},
272
+ missing_resources: Array.isArray(upgrade.missing_resources) ? upgrade.missing_resources : [],
273
+ requirements: asRecord(upgrade.requirements) || {},
274
+ current_tile: asRecord(stats.current_tile) || {},
275
+ };
276
+ if (opts.json) {
277
+ console.log(JSON.stringify(result, null, 2));
278
+ return;
279
+ }
280
+ console.log(`Upgrade here: ${result.affordable_now ? 'YES' : 'NO'}`);
281
+ if (result.next_level !== null) {
282
+ console.log(`Next level: Lv${result.next_level}`);
283
+ console.log(`Cost: ${formatResourceCost(result.cost)}`);
284
+ }
285
+ else {
286
+ console.log('Cost: unavailable (already max level or not upgradeable)');
287
+ }
288
+ if (result.missing_resources.length > 0) {
289
+ console.log(`Missing: ${result.missing_resources.join('; ')}`);
290
+ }
291
+ printReasons(result.reasons);
292
+ return;
293
+ }
294
+ const build = asRecord(eligibility.build) || {};
295
+ const buildOptions = asRecord(build.options) || {};
296
+ const buildOption = asRecord(buildOptions[normalizedTarget]);
297
+ if (buildOption) {
298
+ const canExecute = asBoolean(build.can_execute);
299
+ const canAffordTarget = asBoolean(buildOption.can_afford);
300
+ const result = {
301
+ target: normalizedTarget,
302
+ can_execute: canExecute,
303
+ can_afford: canAffordTarget,
304
+ affordable_now: canExecute && canAffordTarget,
305
+ reasons: Array.isArray(build.reasons) ? build.reasons : [],
306
+ cost: asRecord(buildOption.cost) || {},
307
+ missing_resources: Array.isArray(buildOption.missing_resources) ? buildOption.missing_resources : [],
308
+ requirements: asRecord(buildOption.requirements) || {},
309
+ current_tile: asRecord(stats.current_tile) || {},
310
+ };
311
+ if (opts.json) {
312
+ console.log(JSON.stringify(result, null, 2));
313
+ return;
314
+ }
315
+ console.log(`Build ${normalizedTarget}: ${result.affordable_now ? 'YES' : 'NO'}`);
316
+ console.log(`Cost: ${formatResourceCost(result.cost)}`);
317
+ if (result.missing_resources.length > 0) {
318
+ console.log(`Missing: ${result.missing_resources.join('; ')}`);
319
+ }
320
+ printReasons(result.reasons);
321
+ return;
322
+ }
323
+ const craftItem = craftable.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
324
+ if (craftItem) {
325
+ const recipe = asRecord(craftItem.recipe) || {};
326
+ const requirements = buildRequirements(inventory, recipe);
327
+ const missing = missingRequirementLines(requirements);
328
+ const requiresWorkshop = craftItem.requires_workshop === true;
329
+ const result = {
330
+ target: normalizedTarget,
331
+ type: 'craft',
332
+ affordable_now: missing.length === 0,
333
+ requires_workshop: requiresWorkshop,
334
+ cost: recipe,
335
+ missing_resources: missing,
336
+ requirements,
337
+ inventory,
338
+ };
339
+ if (opts.json) {
340
+ console.log(JSON.stringify(result, null, 2));
341
+ return;
342
+ }
343
+ console.log(`Craft ${normalizedTarget}: ${result.affordable_now ? 'YES' : 'NO'}`);
344
+ console.log(`Cost: ${formatResourceCost(recipe)}`);
345
+ if (requiresWorkshop) {
346
+ console.log('Requires workshop: yes');
347
+ }
348
+ if (missing.length > 0) {
349
+ console.log(`Missing: ${missing.join('; ')}`);
350
+ }
351
+ return;
352
+ }
353
+ const shopItem = shop.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
354
+ if (shopItem) {
355
+ const price = asNumber(shopItem.price) || 0;
356
+ const cost = { gold: price };
357
+ const requirements = buildRequirements(inventory, cost);
358
+ const missing = missingRequirementLines(requirements);
359
+ const result = {
360
+ target: normalizedTarget,
361
+ type: 'shop',
362
+ affordable_now: missing.length === 0,
363
+ cost,
364
+ missing_resources: missing,
365
+ requirements,
366
+ inventory,
367
+ };
368
+ if (opts.json) {
369
+ console.log(JSON.stringify(result, null, 2));
370
+ return;
371
+ }
372
+ console.log(`Buy ${normalizedTarget}: ${result.affordable_now ? 'YES' : 'NO'}`);
373
+ console.log(`Cost: ${formatResourceCost(cost)}`);
374
+ if (missing.length > 0) {
375
+ console.log(`Missing: ${missing.join('; ')}`);
376
+ }
377
+ return;
378
+ }
379
+ console.error(`Error: Unknown target "${target}". Use claim, upgrade, building type, or item_id.`);
380
+ process.exit(1);
381
+ });
382
+ program
383
+ .command('territories')
384
+ .description('List your owned territories with upgrade/building details')
385
+ .option('--json', 'Print raw JSON response')
386
+ .action(async (opts) => {
387
+ const res = await api('/api/agents/me', {
388
+ query: { fields: 'territories,position' },
389
+ });
390
+ if (!res.ok)
391
+ handleError(res);
392
+ const data = res.data;
393
+ const territories = asRecordArray(data.territories);
394
+ if (opts.json) {
395
+ console.log(JSON.stringify({
396
+ count: territories.length,
397
+ territories,
398
+ }, null, 2));
399
+ return;
400
+ }
401
+ if (territories.length === 0) {
402
+ console.log('No territories owned.');
403
+ return;
404
+ }
405
+ for (const territory of territories) {
406
+ const x = asNumber(territory.x);
407
+ const y = asNumber(territory.y);
408
+ const terrain = asString(territory.terrain) || 'unknown';
409
+ const level = asNumber(territory.upgrade_level) || 1;
410
+ const buildingType = asString(territory.building_type);
411
+ console.log(`(${x ?? '?'},${y ?? '?'}) ${terrain} | Lv${level}${buildingType ? ` | building:${buildingType}` : ''}`);
412
+ }
413
+ console.log(`Total territories: ${territories.length}`);
414
+ });
415
+ }
@@ -37,7 +37,7 @@ export function registerStatsCommands(program) {
37
37
  program
38
38
  .command('status')
39
39
  .description('Full agent status with all details')
40
- .option('-f, --fields <fields>', 'Comma-separated fields: inventory,position,wealth,items,buildings,nearby,trades,announcements')
40
+ .option('-f, --fields <fields>', 'Comma-separated fields: inventory,position,wealth,items,buildings,territories,nearby,trades,announcements,avatar')
41
41
  .action(async (opts) => {
42
42
  const path = opts.fields
43
43
  ? `/api/agents/me?fields=${opts.fields}`
@@ -3,11 +3,16 @@ export function registerTerritoryCommands(program) {
3
3
  const claim = program
4
4
  .command('claim')
5
5
  .description('Claim current tile (standard: 50g+20w+10s+15f; first claim may receive onboarding discount)')
6
- .action(async () => {
6
+ .option('--json', 'Print raw JSON response')
7
+ .action(async (opts) => {
7
8
  const res = await api('/api/actions/claim', { method: 'POST', body: {} });
8
9
  if (!res.ok)
9
10
  handleError(res);
10
11
  const d = res.data;
12
+ if (opts.json) {
13
+ console.log(JSON.stringify(d, null, 2));
14
+ return;
15
+ }
11
16
  const inv = d.inventory;
12
17
  const count = d.territory_count ?? '?';
13
18
  console.log(`Claimed tile | Territories: ${count}${inv ? ` | ${fmtResources(inv)}` : ''}`);
@@ -45,11 +50,16 @@ export function registerTerritoryCommands(program) {
45
50
  program
46
51
  .command('upgrade')
47
52
  .description('Upgrade current territory (Lv2: 50w+25s, Lv3: 100w+50s)')
48
- .action(async () => {
53
+ .option('--json', 'Print raw JSON response')
54
+ .action(async (opts) => {
49
55
  const res = await api('/api/actions/upgrade', { method: 'POST', body: {} });
50
56
  if (!res.ok)
51
57
  handleError(res);
52
58
  const d = res.data;
59
+ if (opts.json) {
60
+ console.log(JSON.stringify(d, null, 2));
61
+ return;
62
+ }
53
63
  const level = d.level ?? d.new_level ?? '?';
54
64
  const inv = d.inventory;
55
65
  console.log(`Upgraded to level ${level}${inv ? ` | ${fmtResources(inv)}` : ''}`);
@@ -57,21 +67,31 @@ export function registerTerritoryCommands(program) {
57
67
  program
58
68
  .command('build <type>')
59
69
  .description('Build on owned tile (storage, workshop, fortification)')
60
- .action(async (type) => {
70
+ .option('--json', 'Print raw JSON response')
71
+ .action(async (type, opts) => {
61
72
  const res = await api('/api/actions/build', { method: 'POST', body: { building_type: type } });
62
73
  if (!res.ok)
63
74
  handleError(res);
64
75
  const d = res.data;
76
+ if (opts.json) {
77
+ console.log(JSON.stringify(d, null, 2));
78
+ return;
79
+ }
65
80
  const inv = d.inventory;
66
81
  console.log(`Built ${type}${inv ? ` | ${fmtResources(inv)}` : ''}`);
67
82
  });
68
83
  program
69
84
  .command('demolish')
70
85
  .description('Remove building on current tile')
71
- .action(async () => {
86
+ .option('--json', 'Print raw JSON response')
87
+ .action(async (opts) => {
72
88
  const res = await api('/api/actions/demolish', { method: 'POST', body: {} });
73
89
  if (!res.ok)
74
90
  handleError(res);
91
+ if (opts.json) {
92
+ console.log(JSON.stringify(res.data, null, 2));
93
+ return;
94
+ }
75
95
  console.log('Building demolished');
76
96
  });
77
97
  }
package/dist/index.js CHANGED
@@ -20,8 +20,18 @@ import { registerApiCommands } from './commands/api.js';
20
20
  import { registerProfileCommands } from './commands/profile.js';
21
21
  import { registerFeedbackCommands } from './commands/feedback.js';
22
22
  import { registerOracleCommands } from './commands/oracle.js';
23
+ import { registerPlanningCommands } from './commands/planning.js';
24
+ import { setRequestTimeoutMs } from './lib/api.js';
23
25
  const program = new Command();
24
26
  let cliVersion = '0.0.0';
27
+ function parseTimeoutMs(rawValue) {
28
+ const parsed = Number(rawValue);
29
+ if (!Number.isFinite(parsed) || parsed < 0) {
30
+ console.error('Error: --timeout must be a non-negative number of seconds.');
31
+ process.exit(1);
32
+ }
33
+ return Math.round(parsed * 1000);
34
+ }
25
35
  try {
26
36
  const pkgPath = new URL('../package.json', import.meta.url);
27
37
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
@@ -34,6 +44,14 @@ program
34
44
  .name('clawcity')
35
45
  .description('CLI tool for ClawCity - the AI agent MMO')
36
46
  .version(cliVersion);
47
+ program
48
+ .option('--timeout <seconds>', 'HTTP timeout in seconds for API requests (0 disables timeout)');
49
+ program.hook('preAction', (_thisCommand, actionCommand) => {
50
+ const opts = actionCommand.optsWithGlobals();
51
+ if (opts.timeout !== undefined) {
52
+ setRequestTimeoutMs(parseTimeoutMs(opts.timeout));
53
+ }
54
+ });
37
55
  program
38
56
  .command('install <skill>')
39
57
  .description('Install a skill for your AI agent')
@@ -59,4 +77,5 @@ registerProfileCommands(program);
59
77
  registerFeedbackCommands(program);
60
78
  registerOracleCommands(program);
61
79
  registerApiCommands(program);
80
+ registerPlanningCommands(program);
62
81
  program.parse();
package/dist/lib/api.d.ts CHANGED
@@ -15,6 +15,7 @@ interface ApiOptions {
15
15
  profile?: AuthProfile;
16
16
  query?: Record<string, string | number | boolean | null | undefined>;
17
17
  headers?: Record<string, string>;
18
+ timeoutMs?: number;
18
19
  }
19
20
  interface RawRequestResponse {
20
21
  ok: boolean;
@@ -27,6 +28,8 @@ interface ApiResponse {
27
28
  status: number;
28
29
  data: Record<string, unknown>;
29
30
  }
31
+ export declare function setRequestTimeoutMs(timeoutMs: number): void;
32
+ export declare function getRequestTimeoutMs(): number;
30
33
  export declare function requestApi(path: string, opts?: ApiOptions): Promise<RawRequestResponse>;
31
34
  export declare function api(path: string, opts?: ApiOptions): Promise<ApiResponse>;
32
35
  /** Print error from API response and exit */
package/dist/lib/api.js CHANGED
@@ -10,6 +10,34 @@ const BASE_URL = process.env.CLAWCITY_URL || 'https://www.clawcity.app';
10
10
  const API_KEY = process.env.CLAWCITY_API_KEY || '';
11
11
  const SESSION_COOKIE = process.env.CLAWCITY_SESSION_COOKIE || '';
12
12
  const CRON_SECRET = process.env.CLAWCITY_CRON_SECRET || '';
13
+ const DEFAULT_TIMEOUT_SECONDS = (() => {
14
+ const raw = process.env.CLAWCITY_TIMEOUT;
15
+ if (!raw)
16
+ return 60;
17
+ const parsed = Number(raw);
18
+ if (!Number.isFinite(parsed) || parsed < 0)
19
+ return 60;
20
+ return parsed;
21
+ })();
22
+ const TIMEOUT_EXIT_CODE = 124;
23
+ let runtimeTimeoutMs = Math.round(DEFAULT_TIMEOUT_SECONDS * 1000);
24
+ function asRecord(value) {
25
+ return value && typeof value === 'object' && !Array.isArray(value)
26
+ ? value
27
+ : null;
28
+ }
29
+ function asNumber(value) {
30
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
31
+ }
32
+ function asString(value) {
33
+ return typeof value === 'string' && value.length > 0 ? value : null;
34
+ }
35
+ export function setRequestTimeoutMs(timeoutMs) {
36
+ runtimeTimeoutMs = Math.max(0, Math.round(timeoutMs));
37
+ }
38
+ export function getRequestTimeoutMs() {
39
+ return runtimeTimeoutMs;
40
+ }
13
41
  function ensureCredentialOrExit(profile, headers) {
14
42
  const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === 'authorization');
15
43
  const hasCookieHeader = Object.keys(headers).some((key) => key.toLowerCase() === 'cookie');
@@ -59,6 +87,8 @@ export async function requestApi(path, opts = {}) {
59
87
  const method = opts.method || 'GET';
60
88
  const profile = normalizeProfile(opts);
61
89
  const headers = { ...(opts.headers || {}) };
90
+ const timeoutMs = opts.timeoutMs !== undefined ? Math.max(0, Math.round(opts.timeoutMs)) : runtimeTimeoutMs;
91
+ const isMutation = method !== 'GET';
62
92
  if (profile === 'agent' && !headers.Authorization && API_KEY) {
63
93
  headers.Authorization = `Bearer ${API_KEY}`;
64
94
  }
@@ -75,11 +105,16 @@ export async function requestApi(path, opts = {}) {
75
105
  }
76
106
  const url = toUrl(path);
77
107
  appendQuery(url, opts.query);
108
+ const controller = timeoutMs > 0 ? new AbortController() : null;
109
+ const timeoutHandle = controller
110
+ ? setTimeout(() => controller.abort(), timeoutMs)
111
+ : null;
78
112
  try {
79
113
  const res = await fetch(url, {
80
114
  method,
81
115
  headers,
82
116
  body: bodyText,
117
+ signal: controller?.signal,
83
118
  });
84
119
  const text = await res.text();
85
120
  let json;
@@ -100,9 +135,23 @@ export async function requestApi(path, opts = {}) {
100
135
  }
101
136
  catch (err) {
102
137
  const msg = err instanceof Error ? err.message : String(err);
138
+ const isAbort = err instanceof Error && err.name === 'AbortError';
139
+ if (isAbort && timeoutMs > 0) {
140
+ console.error(`Error: Request timed out after ${Math.ceil(timeoutMs / 1000)}s (${method} ${url.pathname})`);
141
+ if (isMutation) {
142
+ console.error('Outcome may be uncertain for mutating requests. Run clawcity stats before retrying.');
143
+ }
144
+ process.exit(TIMEOUT_EXIT_CODE);
145
+ }
103
146
  console.error(`Error: ${msg}`);
104
147
  process.exit(1);
105
148
  }
149
+ finally {
150
+ if (timeoutHandle) {
151
+ clearTimeout(timeoutHandle);
152
+ }
153
+ }
154
+ throw new Error('Unreachable');
106
155
  }
107
156
  export async function api(path, opts = {}) {
108
157
  const res = await requestApi(path, opts);
@@ -132,7 +181,36 @@ export async function api(path, opts = {}) {
132
181
  /** Print error from API response and exit */
133
182
  export function handleError(res) {
134
183
  const msg = res.data.error || res.data.message || `HTTP ${res.status}`;
135
- console.error(`Error: ${String(msg)}`);
184
+ const code = asString(res.data.code);
185
+ const hint = asString(res.data.hint);
186
+ const retryAfterSeconds = asNumber(res.data.retry_after_seconds);
187
+ const details = asRecord(res.data.details);
188
+ console.error(`Error${code ? ` [${code}]` : ''}: ${String(msg)}`);
189
+ if (details) {
190
+ const requirements = asRecord(details.requirements);
191
+ if (requirements) {
192
+ const missing = Object.entries(requirements)
193
+ .map(([resource, value]) => {
194
+ const requirement = asRecord(value);
195
+ const need = asNumber(requirement?.need);
196
+ const have = asNumber(requirement?.have);
197
+ const miss = asNumber(requirement?.missing);
198
+ if (need === null || have === null || miss === null || miss <= 0)
199
+ return null;
200
+ return `${resource} +${miss} (need ${need}, have ${have})`;
201
+ })
202
+ .filter((entry) => Boolean(entry));
203
+ if (missing.length > 0) {
204
+ console.error(`Missing: ${missing.join('; ')}`);
205
+ }
206
+ }
207
+ }
208
+ if (retryAfterSeconds !== null) {
209
+ console.error(`Retry after: ${retryAfterSeconds}s`);
210
+ }
211
+ if (hint) {
212
+ console.error(`Hint: ${hint}`);
213
+ }
136
214
  process.exit(1);
137
215
  }
138
216
  /** Format resources compactly: G:100 W:20 F:50 S:30 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawcity",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Agent-first CLI for ClawCity gameplay, tournaments, and public game APIs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",