references/cookiy/scripts/cookiy.sh
A supporting file of the user-research-cookiy skill, shown as source.
#!/usr/bin/env bash
# Cookiy CLI — standalone shell client for Cookiy AI.
# Run in a terminal: bash cookiy.sh <command>
# Requires: bash, curl, jq, grep, sed.
# Docs: ../cookiy.md (same directory tree as this script).
set -euo pipefail
VERSION="1.21.0"
DEFAULT_SERVER_URL="https://s-api.cookiy.ai"
DEFAULT_TOKEN_PATH="${COOKIY_CREDENTIALS:-$HOME/.cookiy/token.txt}"
# Long-running API call timeout (seconds); override with COOKIY_API_RPC_TIMEOUT or legacy COOKIY_MCP_RPC_TIMEOUT.
API_CALL_TIMEOUT="${COOKIY_API_RPC_TIMEOUT:-${COOKIY_MCP_RPC_TIMEOUT:-600}}"
TIMEOUT=120
RPC_ID=0
# --- helpers ---------------------------------------------------------------
die() { echo "$1" >&2; exit "${2:-1}"; }
next_id() { RPC_ID=$((RPC_ID + 1)); echo "$RPC_ID"; }
# Extract a top-level string value from a flat JSON file/string.
# Usage: json_get <key> < file_or_string
# Handles: "key": "value" and "key":"value"
json_get() {
sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1
}
# JSON-escape a string (handles quotes, backslashes, newlines)
json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | tr '\n' ' '
}
# Add or overwrite a key in BUILT_JSON. Value must be a raw JSON literal
# (e.g. '"url"', 'true', '300000').
json_set() {
local key="$1" val="$2"
# Remove existing key (if any) then append
BUILT_JSON="$(printf '%s' "$BUILT_JSON" | sed 's/,"'"$key"'":[^,}]*//;s/{"'"$key"'":[^,}]*,/{/')"
BUILT_JSON="${BUILT_JSON%\}},\"$key\":$val}"
}
# Remove a key from BUILT_JSON.
json_del() {
local key="$1"
BUILT_JSON="$(printf '%s' "$BUILT_JSON" | sed 's/,"'"$key"'":[^,}]*//;s/{"'"$key"'":[^,}]*,/{/')"
# Handle case where it's the only key
BUILT_JSON="$(printf '%s' "$BUILT_JSON" | sed 's/{"'"$key"'":[^}]*}/{}/')"
}
# Read a string/number value from BUILT_JSON (no jq).
built_get() {
echo "$BUILT_JSON" | json_get "$1"
}
# Read a numeric value with fallback default (no jq).
built_get_num() {
local val
val="$(echo "$BUILT_JSON" | sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -1)"
echo "${val:-$2}"
}
usage() {
cat <<EOF
Cookiy CLI v${VERSION} (standalone shell client)
Run in a terminal: bash cookiy.sh <command>
Usage:
cookiy.sh [--token <path>] [--api-url <url>] <command> ...
Global options:
--api-url Full JSON-RPC endpoint URL (overrides COOKIY_API_URL)
--token Path to raw token file (default: ~/.cookiy/token.txt; env COOKIY_CREDENTIALS)
Environment:
COOKIY_CREDENTIALS Path to token file (same as --token)
COOKIY_API_URL Full API endpoint URL (default: <server>/mcp)
COOKIY_API_RPC_TIMEOUT Seconds for blocking API calls (default: 600)
COOKIY_SERVER_URL API origin when endpoint URL not set (default: https://s-api.cookiy.ai)
Commands:
save-token <token> Save raw access token (validates against API first)
help Offline CLI reference
study list|create|status|upload|.. Includes guide|interview|run-synthetic-user|report
recruit start Qualitative or quant recruitment (auto-detects mode)
quant list|create|get|update|status|report|raw-response Quantitative survey management (keyed by survey-id)
billing balance|checkout|price-table|transactions
user info Current logged-in user details and limits
Examples:
cookiy.sh save-token eyJhbGciOi...
cookiy.sh help commands
cookiy.sh study list --limit 10
cookiy.sh study create --query "..."
cookiy.sh study report generate --study-id 123 --skip-synthetic-interview
cookiy.sh study report wait --study-id 123 --timeout-ms 300000
cookiy.sh billing transactions --limit 50
EOF
}
# --- login URL + unified auth-failure handler ------------------------------
resolve_server_base() {
local base="${SERVER_URL_OPT:-${COOKIY_SERVER_URL:-}}"
echo "${base:-$DEFAULT_SERVER_URL}"
}
resolve_login_url() {
echo "$(resolve_server_base)/oauth/cli/start"
}
die_no_access() {
local url
url="$(resolve_login_url)"
die "Access denied — token is missing or expired.
Sign in: $url"
}
# Inspect any JSON body for auth-error indicators (status_code 401 or
# error.code UNAUTHORIZED/AUTH_REQUIRED). If detected, call die_no_access so
# the user always sees the sign-in URL regardless of which layer returned
# the auth failure (HTTP, JSON-RPC, or tool result).
check_auth_error() {
local body="$1"
local sc code
sc="$(echo "$body" | jq -r 'if type == "object" then (.status_code // empty) else empty end' 2>/dev/null)" || return 0
case "$sc" in 401) die_no_access ;; esac
code="$(echo "$body" | jq -r 'if type == "object" then (.error.code // empty) else empty end' 2>/dev/null)" || return 0
case "$code" in UNAUTHORIZED|AUTH_REQUIRED) die_no_access ;; esac
}
# --- save-token command ----------------------------------------------------
run_save_token() {
command -v jq >/dev/null 2>&1 || die "cookiy.sh requires jq"
local input="$1"
input="$(printf '%s' "$input" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/\r$//')"
[[ -n "$input" ]] || die "Usage: cookiy.sh save-token <access_token_or_json>"
local at=""
if printf '%s' "$input" | jq -e '.access_token' >/dev/null 2>&1; then
at="$(printf '%s' "$input" | jq -r '.access_token')"
else
at="$input"
fi
[[ -n "$at" ]] || die "Could not find access_token in input."
local raw_server api_end
raw_server="$(resolve_server_base)"
api_end="${API_URL_OPT:-${COOKIY_API_URL:-${COOKIY_MCP_URL:-}}}"
api_end="${api_end:-${raw_server%/}/mcp}"
mkdir -p "$(dirname "$TOKEN_PATH")"
printf '%s' "$at" > "$TOKEN_PATH"
chmod 600 "$TOKEN_PATH" 2>/dev/null || true
echo "Token saved to $TOKEN_PATH" >&2
}
# --- token file & URL resolution ------------------------------------------
TOKEN_PATH="$DEFAULT_TOKEN_PATH"
SERVER_URL_OPT=""
API_URL_OPT=""
ACCESS_TOKEN=""
API_ENDPOINT=""
load_credentials() {
[[ -f "$TOKEN_PATH" ]] || die_no_access
ACCESS_TOKEN="$(tr -d '\r\n' < "$TOKEN_PATH" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ -n "$ACCESS_TOKEN" ]] || die_no_access
}
resolve_api_endpoint() {
if [[ -n "$API_URL_OPT" ]]; then API_ENDPOINT="$API_URL_OPT"; return; fi
if [[ -n "${COOKIY_API_URL:-${COOKIY_MCP_URL:-}}" ]]; then API_ENDPOINT="${COOKIY_API_URL:-${COOKIY_MCP_URL:-}}"; return; fi
local base="${SERVER_URL_OPT:-${COOKIY_SERVER_URL:-}}"
base="${base:-$DEFAULT_SERVER_URL}"
API_ENDPOINT="${base%/}/mcp"
}
# --- JSON-RPC over curl ----------------------------------------------------
# POST JSON-RPC body to API_ENDPOINT; print response body on HTTP 200 only.
# On failure: print HTTP code, response snippet, and curl errors to stderr; return 1.
post_jsonrpc() {
local payload="$1"
local resp http body cerr
cerr="$(mktemp -t cookiycurl.XXXXXX 2>/dev/null || mktemp)"
resp="$(curl -sS --max-time "$API_CALL_TIMEOUT" -w '\n%{http_code}' \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "$payload" \
"$API_ENDPOINT" 2>"$cerr")" || {
[[ -s "$cerr" ]] && echo "curl: $(cat "$cerr")" >&2
rm -f "$cerr"
return 1
}
rm -f "$cerr"
http="$(printf '%s' "$resp" | tail -n1)"
body="$(printf '%s' "$resp" | sed '$d')"
if [[ "$http" != "200" ]]; then
if [[ "$http" == "401" ]]; then
die_no_access
fi
echo "HTTP $http — POST $API_ENDPOINT" >&2
if [[ -n "${body// /}" ]]; then
echo "$body" | head -c 4000 >&2
echo >>/dev/stderr
fi
return 1
fi
printf '%s' "$body"
}
# Check if a JSON-RPC response has an error. Prints error message to stderr and returns 1 if so.
check_rpc_error() {
local resp="$1"
# Use jq to reliably extract JSON-RPC error (handles nested objects)
local err_msg
err_msg="$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null)"
if [[ -n "$err_msg" ]]; then
local err_data
err_data="$(echo "$resp" | jq -r '.error.data // empty' 2>/dev/null)"
if [[ -n "$err_data" ]]; then
echo "$err_msg: $err_data" >&2
else
echo "$err_msg" >&2
fi
return 1
fi
return 0
}
# Read full JSON-RPC tools/call response on stdin; print CLI-facing result.
#
# Read path only (how we parse the JSON-RPC body — no network, no writes):
# 1. Success payload: .result.structuredContent.data only (no result.data fallback).
# 2. Success with null/missing data → print nothing.
# 3. Failure → full .result.structuredContent envelope when structuredContent.ok == false.
#
# content[0].text is ignored (agent/chat UIs only).
emit_tool_result() {
local raw
raw="$(cat)"
echo "$raw" | jq -r '
.result as $r
| ($r.structuredContent // null) as $sc
| (if $sc != null and ($sc | has("data")) then $sc.data else null end) as $payload
| if $payload != null then
$payload
elif $sc != null and $sc.ok == false then
$sc
else
empty
end
'
}
# invoke <tool_name> <arguments_json>
# Performs the 3-step JSON-RPC handshake: initialize, notify, tools/call
invoke() {
command -v jq >/dev/null 2>&1 || die "cookiy.sh requires jq"
local tool_name="$1"
local args_json="${2:-\{\}}"
# 1) initialize
local init_resp
init_resp="$(post_jsonrpc "{\"jsonrpc\":\"2.0\",\"id\":$(next_id),\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"cookiy-cli-sh\",\"version\":\"1.0.0\"}}}")" \
|| die "API initialize request failed"
check_auth_error "$init_resp"
check_rpc_error "$init_resp" || die "API initialize error"
# 2) notifications/initialized
post_jsonrpc '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null 2>&1 || true
# 3) tools/call
local call_resp
call_resp="$(post_jsonrpc "{\"jsonrpc\":\"2.0\",\"id\":$(next_id),\"method\":\"tools/call\",\"params\":{\"name\":\"${tool_name}\",\"arguments\":${args_json}}}")" \
|| die "API tools/call request failed"
check_auth_error "$call_resp"
check_rpc_error "$call_resp" || exit 1
local printable
printable="$(echo "$call_resp" | emit_tool_result)"
check_auth_error "$printable"
echo "$printable"
# Exit non-zero if tool returned ok:false. Guard with type == "object" so
# plain-text results (from tools that set a custom contentText) do not
# trigger jq errors or false positives.
if echo "$printable" | jq -e 'type == "object" and .ok == false' >/dev/null 2>&1; then
return 1
fi
}
# Poll study status until guide generation leaves "guide_generation_in_progress"
# or the deadline passes; on success print the full guide-get response.
wait_for_guide() {
local study_id="$1"
local timeout_ms="${2:-120000}"
local deadline=$(( $(date +%s) + (timeout_ms + 999) / 1000 ))
local args="{\"study_id\":\"$(json_escape "$study_id")\"}"
local resp guide status timed_out=0
guide='{}'
while :; do
resp="$(invoke cookiy_activity_get "$args")" || {
[[ -n "${resp:-}" ]] && echo "$resp"
return 1
}
guide="$(echo "$resp" | jq -c '.sources.guide // {}')"
status="$(echo "$resp" | jq -r '.sources.guide.status // ""')"
if [[ "$status" == "guide_generation_failed" || "$status" == "failed" ]]; then
echo "$guide"
return 1
fi
[[ "$status" != "guide_generation_in_progress" ]] && break
if [[ $(date +%s) -ge $deadline ]]; then timed_out=1; break; fi
sleep 15
done
if [[ $timed_out -ne 0 ]]; then
echo "$guide"
return 1
fi
invoke cookiy_guide_get "$args"
}
# Poll study status until report generation leaves "report_generation_in_progress"
# or the deadline passes, then print the report share link.
# Returns 1 on timeout or invoke failure.
wait_for_report_then_link() {
local study_id="$1"
local timeout_ms="${2:-300000}"
local deadline=$(( $(date +%s) + (timeout_ms + 999) / 1000 ))
local args="{\"study_id\":\"$(json_escape "$study_id")\"}"
local resp status
resp='{}'
while :; do
resp="$(invoke cookiy_activity_get "$args")" || {
[[ -n "${resp:-}" ]] && echo "$resp"
return 1
}
status="$(echo "$resp" | jq -r '.sources.report.status // ""')"
[[ "$status" != "report_generation_in_progress" ]] && break
if [[ $(date +%s) -ge $deadline ]]; then
echo "$resp"
return 1
fi
sleep 15
done
invoke cookiy_report_share_link_get "$args"
}
# --- arg builder -----------------------------------------------------------
# Parses --key value pairs from "$@" and builds a JSON object.
# Only includes keys listed in the allowed-keys spec.
# Usage: build_json "key1 key2 key3" "$@"
# Numeric keys: limit, amount_usd_cents, persona_count, incremental_participants,
# max_chars, top_values_per_question, sample_open_text_values
# survey_id: digits only → JSON number (LimeSurvey sid); otherwise string
# Boolean keys: include_raw, skip_synthetic_interview, include_incomplete
# (study interview list → cookiy_interview_list: only study_id + optional cursor; no include_simulation)
# The rest are strings.
# Sets global: BUILT_JSON, ARG_JSON_RAW, ARG_POSITIONALS
ARG_JSON_RAW=""
ARG_POSITIONALS=""
# CLI string → JSON true/false (API expects boolean, not "true" strings)
bool_json() {
case "$1" in
true|True|TRUE|1|yes|Yes|YES|on|On|ON) echo true ;;
false|False|FALSE|0|no|No|NO|off|Off|OFF) echo false ;;
*) die "Invalid boolean value: $1 (use true or false)" ;;
esac
}
build_json() {
local allowed="$1"; shift
local -a pos=()
local json="{"
local first=true
ARG_JSON_RAW=""
ARG_POSITIONALS=""
while [[ $# -gt 0 ]]; do
if [[ "$1" == --* ]]; then
local key="${1#--}"
key="${key//-/_}"
if [[ $# -gt 1 && "${2:0:2}" != "--" ]]; then
local val="$2"; shift 2
else
local val="true"; shift
fi
# Special flags: never forwarded as tool JSON fields
if [[ "$key" == "json" ]]; then ARG_JSON_RAW="$val"; continue; fi
# Skip if not in allowed list
case " $allowed " in
*" $key "*) ;;
*) echo "Warning: unknown flag --${key//_/-} (ignored)" >&2; continue ;;
esac
$first || json+=","
first=false
case "$key" in
limit|amount_usd_cents|persona_count|incremental_participants|timeout_ms|max_chars|top_values_per_question|sample_open_text_values)
[[ "$val" =~ ^-?[0-9]+$ ]] || die "--${key//_/-} requires an integer, got: $val"
# amount_usd_cents → API param name amount_cents
local json_key="$key"
[[ "$key" == "amount_usd_cents" ]] && json_key="amount_cents"
json+="\"$json_key\":$val" ;;
survey_id)
json+="\"$key\":\"$(json_escape "$val")\"" ;;
include_raw|skip_synthetic_interview|include_incomplete)
json+="\"$key\":$(bool_json "$val")" ;;
attachments)
# JSON array passthrough — validated via jq when available.
if command -v jq >/dev/null 2>&1; then
echo "$val" | jq -e 'type == "array"' >/dev/null 2>&1 \
|| die "--attachments requires a JSON array, got: $val"
else
[[ "${val:0:1}" == "[" && "${val: -1}" == "]" ]] \
|| die "--attachments requires a JSON array, got: $val"
fi
json+="\"$key\":$val" ;;
*)
json+="\"$key\":\"$(json_escape "$val")\"" ;;
esac
else
pos+=("$1"); shift
fi
done
json+="}"
BUILT_JSON="$json"
ARG_POSITIONALS="${pos[*]+"${pos[*]}"}"
}
# Merge ARG_JSON_RAW into BUILT_JSON (shallow merge via string manipulation)
# This is a best-effort merge for flat objects.
merge_raw_json() {
if [[ -z "$ARG_JSON_RAW" || "$ARG_JSON_RAW" == "{}" ]]; then return; fi
local base="$BUILT_JSON"
local extra="$ARG_JSON_RAW"
# Strip outer braces
base="${base#\{}"
base="${base%\}}"
extra="${extra#\{}"
extra="${extra%\}}"
if [[ -z "$base" ]]; then
BUILT_JSON="{$extra}"
elif [[ -z "$extra" ]]; then
BUILT_JSON="{$base}"
else
BUILT_JSON="{$base,$extra}"
fi
}
# Require a key exists in BUILT_JSON (simple check)
require_key() {
local key="$1"
local msg="$2"
if ! echo "$BUILT_JSON" | grep -q "\"$key\""; then
die "$msg"
fi
}
# Require key present and value not empty string (still allows numeric / non-string JSON values)
require_non_empty_string_value() {
local key="$1" msg="$2"
require_key "$key" "$msg"
if echo "$BUILT_JSON" | grep -qE "\"$key\"[[:space:]]*:[[:space:]]*\"\"(,|})"; then
die "$msg"
fi
}
# Extract study_id from a JSON response string
extract_study_id() {
local r="$1"
local sid
sid="$(echo "$r" | json_get study_id)"
if [[ -z "$sid" ]]; then sid="$(echo "$r" | json_get studyId)"; fi
if [[ -z "$sid" ]]; then sid="$(echo "$r" | json_get id)"; fi
echo "$sid"
}
# Local CLI reference (no credentials; printed by: help | help commands | help cli)
# Layout: POSIX/man-inspired sections; Usage + Flags blocks similar to Cobra/docker-style --help.
print_cli_commands_reference() {
cat <<EOF
NAME
cookiy.sh — standalone Cookiy AI CLI (bash, curl, jq)
SYNOPSIS
cookiy.sh [GLOBAL OPTION ...] <command> [ARG ...]
VERSION
${VERSION}
DESCRIPTION
Standalone shell client for the Cookiy AI platform.
Run in a terminal: bash cookiy.sh <command>
Long options use kebab-case; they are sent as snake_case JSON fields (e.g. --study-id → study_id).
--json merges extra JSON fields into the tool request, or provides the guide patch payload.
Numeric sid for quant: --survey-id 12345 becomes JSON number when value is all digits.
GLOBAL OPTIONS
--token <path> Raw token file (default ~/.cookiy/token.txt; same as COOKIY_CREDENTIALS)
--api-url <url> Full JSON-RPC endpoint URL (overrides COOKIY_API_URL)
ENVIRONMENT
COOKIY_CREDENTIALS Path to raw token file
COOKIY_API_URL Full JSON-RPC endpoint URL
COOKIY_API_RPC_TIMEOUT Max seconds for API calls (default 600)
COOKIY_SERVER_URL API origin if endpoint URL not set
DOCUMENTATION
references/cookiy/cli/commands.md (if present in repo)
COMMANDS
help — offline CLI reference
Usage: cookiy.sh help
Note: Prints this reference. No credentials needed.
study list — list studies
Usage: cookiy.sh study list [--limit <n>] [--cursor <s>]
Flags: --limit <integer> --cursor <string>
study create — create study from natural language
Usage: cookiy.sh study create --query <s> [--thinking <s>] [--attachments <json-array>]
Flags: --query <string> (required)
--thinking <string>
--attachments <json-array> JSON array, e.g. '[{"s3_key":"...","description":"..."}]'.
study status — study record and activity
Usage: cookiy.sh study status --study-id <uuid>
Flags: --study-id (required)
Calls cookiy_activity_get which server-side merges the study record and activity summary.
study guide get
Usage: cookiy.sh study guide get --study-id <uuid>
Flags: --study-id (required)
study guide update — apply patch to discussion guide
Usage: cookiy.sh study guide update --study-id <uuid> --base-revision <s> --idempotency-key <s> [--change-message <s>] --json '<patch>'
Flags: --study-id (required) --base-revision (required) --idempotency-key (required) --json (required)
--change-message
study guide wait — poll until guide generation completes, then return guide get JSON
Usage: cookiy.sh study guide wait --study-id <uuid> [--timeout-ms <n>]
Flags: --study-id (required) --timeout-ms <n> (default 120000)
Output: The same JSON shape as study guide get.
Behavior: Polls every 15s while sources.guide.status == "guide_generation_in_progress".
Exits 0 when the status transitions to any other value; exits 1 on timeout.
study upload — attach media (image upload)
Usage: cookiy.sh study upload --content-type <s> (--image-data <s> | --image-url <s>)
Flags: --content-type (required) --image-data | --image-url (one required)
study interview list | playback url|content
Usage: cookiy.sh study interview list --study-id <uuid> [--cursor <s>]
cookiy.sh study interview playback url --study-id <uuid> [--interview-id <uuid>] [--cursor <s>]
cookiy.sh study interview playback content --study-id <uuid> [--interview-id <uuid>] [--cursor <s>]
Note: list always includes synthetic interviews in results (not configurable via CLI).
When --interview-id is omitted, playback returns a paginated list (default 20).
Use --cursor to fetch subsequent pages.
study run-synthetic-user start — run synthetic user interviews
Usage: cookiy.sh study run-synthetic-user start --study-id <uuid> [--persona-count <n>] [--plain-text <s>]
Flags: --study-id (required)
--persona-count <integer> Number of synthetic interviews to run
--plain-text <string> Participant persona / profile description (maps to API interviewee_persona)
recruit start — launch participant recruitment
Usage: cookiy.sh recruit start [--study-id <uuid>] [--survey-public-url <url>] [--confirmation-token <s>] [--plain-text <s>] [--incremental-participants <n>]
Flags: --study-id (qualitative — required for interview studies)
--survey-public-url (quant — auto-sets recruit_mode=quant_survey; study-id optional)
Output: Full API envelope JSON (preview includes top-level sample_size, target_group, payment_quote, derived_languages).
Note: incremental_participants is auto-capped to remaining sample size capacity. If below current channel target, treated as incremental ("recruit N more").
study report generate | content | link | wait
Usage: cookiy.sh study report generate --study-id <uuid> [--skip-synthetic-interview]
cookiy.sh study report content --study-id <uuid>
cookiy.sh study report link --study-id <uuid>
cookiy.sh study report wait --study-id <uuid> [--timeout-ms <n>]
wait polls study status every 15s while
sources.report.status == "report_generation_in_progress", then prints the
report share link. Exits 1 on timeout (default 300000ms).
quant list — list surveys
Usage: cookiy.sh quant list
Note: Lists all surveys visible to the operator (sid, title, active, language).
quant create — create survey (multi-language)
Usage: cookiy.sh quant create --json '<obj>'
Flags: --json (required): JSON with survey_title, languages[], groups[], quotas[], etc.
Multi-lang: Set "languages":["en","zh","ja"] and use per-language maps for text fields.
Respondents can switch language on the survey page.
Schema: See cookiy-quant-create-schema.md for full field reference.
quant get — survey detail
Usage: cookiy.sh quant get --survey-id <n>
Flags: --survey-id (required, numeric)
quant update — patch survey
Usage: cookiy.sh quant update --survey-id <n> --json '<obj>'
Flags: --survey-id (required, numeric)
--json (required): JSON with survey, groups, questions, quotas_create, quotas_update, etc.
quant status — combined survey + panel recruitment status
Usage: cookiy.sh quant status --survey-id <n>
Flags: --survey-id <integer> Numeric LimeSurvey sid from \`quant list\`
Output: Single JSON envelope wrapping both sides:
{ survey_id, survey: { completed_responses, incomplete_responses, full_responses },
recruit: { total_bought, total_completed } }.
When no recruit project exists yet (recruit not started), the recruit block
reports zeros instead of erroring.
quant report — survey report (structured JSON)
Usage: cookiy.sh quant report --survey-id <n>
Flags: --survey-id (required, numeric)
Output: JSON on stdout — aggregates (distributions/labels/percentages/numeric stats/
completion funnel) + raw data (results_json, raw_participants).
Raw data is auto-included; max_chars cap of 120K chars prevents context explosion.
quant raw-response — raw survey responses as CSV
Usage: cookiy.sh quant raw-response --survey-id <n> [--include-incomplete]
Flags: --survey-id (required, numeric)
--include-incomplete (bool; default false — exclude incomplete responses)
Output: Raw CSV text on stdout (no JSON envelope). Output can be large —
redirect to a file, e.g. \`cookiy.sh quant raw-response --survey-id 12345 > responses.csv\`.
billing balance
Usage: cookiy.sh billing balance
Output: one plain-text line (balance_summary from API).
billing transactions — wallet ledger
Usage: cookiy.sh billing transactions [--limit <n>] [--cursor <iso8601>] [--study-id <uuid>] [--survey-id <sid>]
Output: pretty-printed JSON array (agent-friendly; same fields as GET /v1/billing/transactions).
Note: MCP tool cookiy_billing_transactions (same JSON-RPC session as balance/checkout).
billing checkout
Usage: cookiy.sh billing checkout --amount-usd-cents <n>
Flags: USD integer cents (min 100); internally mapped to API amount_cents.
billing price-table
Usage: cookiy.sh billing price-table
Output: Current pricing table for all Cookiy operations (fetched from server).
user info
Usage: cookiy.sh user info
Output: Information about the currently logged-in user, including limitations.
BOOLEAN FLAGS (values: true | false | 1 | 0 | yes | no | on | off)
--include-raw --skip-synthetic-interview --include-incomplete
save-token — store raw access token from browser sign-in
Usage: cookiy.sh save-token <access_token>
cookiy.sh save-token '{"access_token":"eyJ..."}'
Flow: Verifies the token against the API, then writes raw token to --token path.
Get token: open the sign-in page at {server}/oauth/cli/start, log in, copy the token.
Needs: jq, curl
FILES
Default token file: ~/.cookiy/token.txt
EOF
}
# === Parse global options ==================================================
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
# Undocumented in usage/help; for internal API base override only.
--server-url) SERVER_URL_OPT="$2"; shift 2 ;;
--api-url|--mcp-url) API_URL_OPT="$2"; shift 2 ;;
--token|--credentials) TOKEN_PATH="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) ARGS+=("$1"); shift ;;
esac
done
[[ ${#ARGS[@]} -gt 0 ]] || { usage; exit 0; }
CMD="${ARGS[0]}"
TAIL=("${ARGS[@]:1}")
case "$CMD" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$VERSION"; exit 0 ;;
esac
# Local CLI manual: no credentials needed
if [[ "$CMD" == "help" ]]; then
print_cli_commands_reference
exit 0
fi
# save-token: no prior credentials required
if [[ "$CMD" == "save-token" ]]; then
[[ ${#TAIL[@]} -ge 1 ]] || die "Usage: cookiy.sh save-token <access_token_or_json>"
run_save_token "${TAIL[*]}"
exit 0
fi
# All commands below need credentials
load_credentials
resolve_api_endpoint
# === COMMANDS ==============================================================
case "$CMD" in
study)
sub="${TAIL[0]:-}"
stail=("${TAIL[@]:1}")
case "$sub" in
list)
build_json "limit cursor" "${stail[@]+"${stail[@]}"}"
invoke cookiy_study_list "$BUILT_JSON"
;;
status)
build_json "study_id" "${stail[@]+"${stail[@]}"}"
require_key study_id "study status requires --study-id"
# Server-side merge: cookiy_activity_get now spreads cookiy_study_get's
# normalised fields at the top of its data payload, so a single call
# covers both the study record and the activity summary.
invoke cookiy_activity_get "$BUILT_JSON"
;;
create)
build_json "query thinking attachments" "${stail[@]+"${stail[@]}"}"
require_key query "study create requires --query"
invoke cookiy_study_create "$BUILT_JSON"
;;
upload)
build_json "image_data image_url content_type" "${stail[@]+"${stail[@]}"}"
require_key content_type "study upload requires --content-type"
invoke cookiy_media_upload "$BUILT_JSON"
;;
guide)
gcmd="${stail[0]:-}"
gtail=("${stail[@]:1}")
case "$gcmd" in
get)
build_json "study_id" "${gtail[@]+"${gtail[@]}"}"
require_key study_id "study guide get requires --study-id"
invoke cookiy_guide_get "$BUILT_JSON"
;;
update)
build_json "study_id base_revision idempotency_key change_message" "${gtail[@]+"${gtail[@]}"}"
require_key study_id "study guide update requires --study-id"
require_key base_revision "study guide update requires --base-revision"
require_key idempotency_key "study guide update requires --idempotency-key"
[[ -n "$ARG_JSON_RAW" ]] || die "study guide update requires --json '<patch>'"
# Inject patch key: strip trailing }, append ,"patch":...}
BUILT_JSON="${BUILT_JSON%\}},\"patch\":$ARG_JSON_RAW}"
invoke cookiy_guide_patch "$BUILT_JSON"
;;
wait)
build_json "study_id timeout_ms" "${gtail[@]+"${gtail[@]}"}"
require_key study_id "study guide wait requires --study-id"
study_id_value="$(built_get study_id)"
timeout_ms_value="$(built_get_num timeout_ms 120000)"
wait_for_guide "$study_id_value" "$timeout_ms_value"
;;
*) die "Unknown: study guide ${gcmd:-} (get|update|wait)" ;;
esac
;;
interview)
isub="${stail[0]:-}"
itail=("${stail[@]:1}")
case "$isub" in
list)
build_json "study_id cursor" "${itail[@]+"${itail[@]}"}"
require_key study_id "study interview list requires --study-id"
invoke cookiy_interview_list "$BUILT_JSON"
;;
playback)
psub="${itail[0]:-}"
ptail=("${itail[@]:1}")
case "$psub" in
url)
build_json "study_id interview_id cursor" "${ptail[@]+"${ptail[@]}"}"
require_key study_id "study interview playback url requires --study-id"
json_set view '"url"'
invoke cookiy_interview_playback_get "$BUILT_JSON"
;;
content)
build_json "study_id interview_id cursor" "${ptail[@]+"${ptail[@]}"}"
require_key study_id "study interview playback content requires --study-id"
json_set view '"transcript"'
invoke cookiy_interview_playback_get "$BUILT_JSON"
;;
*) die "study interview playback url|content --study-id <uuid> [--interview-id <uuid>]" ;;
esac
;;
*) die "Unknown study interview subcommand: ${isub:-}" ;;
esac
;;
run-synthetic-user)
ssub="${stail[0]:-}"
srest=("${stail[@]:1}")
case "$ssub" in
start)
build_json "study_id persona_count plain_text" "${srest[@]+"${srest[@]}"}"
require_key study_id "study run-synthetic-user start requires --study-id"
# Server accepts plain_text directly (FakeInterviewGenerateSchema);
# no client-side remap needed.
invoke cookiy_simulated_interview_generate "$BUILT_JSON"
;;
*) die "study run-synthetic-user start" ;;
esac
;;
report)
rsub="${stail[0]:-}"
rrest=("${stail[@]:1}")
case "$rsub" in
generate)
build_json "study_id skip_synthetic_interview" "${rrest[@]+"${rrest[@]}"}"
require_key study_id "study report generate requires --study-id"
invoke cookiy_report_generate "$BUILT_JSON"
;;
content)
build_json "study_id" "${rrest[@]+"${rrest[@]}"}"
require_key study_id "study report content requires --study-id"
invoke cookiy_report_content_get "$BUILT_JSON"
;;
link)
build_json "study_id" "${rrest[@]+"${rrest[@]}"}"
require_key study_id "study report link requires --study-id"
invoke cookiy_report_share_link_get "$BUILT_JSON"
;;
wait)
build_json "study_id timeout_ms" "${rrest[@]+"${rrest[@]}"}"
require_key study_id "study report wait requires --study-id"
study_id_value="$(built_get study_id)"
timeout_ms_value="$(built_get_num timeout_ms 300000)"
wait_for_report_then_link "$study_id_value" "$timeout_ms_value"
;;
*) die "study report generate|content|link|wait" ;;
esac
;;
*) die "Unknown study subcommand: ${sub:-(none)}
Available: list, create, status, upload, guide, interview, run-synthetic-user, report" ;;
esac
;;
quant)
sub="${TAIL[0]:-}"
qtail=("${TAIL[@]:1}")
case "$sub" in
list)
build_json "" "${qtail[@]+"${qtail[@]}"}"
invoke cookiy_quant_survey_list "$BUILT_JSON"
;;
create)
build_json "" "${qtail[@]+"${qtail[@]}"}"
merge_raw_json
[[ -n "$ARG_JSON_RAW" ]] || die "quant create requires --json '<obj>'"
invoke cookiy_quant_survey_create "$BUILT_JSON"
;;
get)
build_json "survey_id" "${qtail[@]+"${qtail[@]}"}"
require_key survey_id "quant get requires --survey-id (numeric sid from quant list)"
invoke cookiy_quant_survey_detail "$BUILT_JSON"
;;
update)
build_json "survey_id" "${qtail[@]+"${qtail[@]}"}"
merge_raw_json
require_key survey_id "quant update requires --survey-id (numeric sid from quant list)"
[[ -n "$ARG_JSON_RAW" ]] || die "quant update requires --json '<obj>'"
invoke cookiy_quant_survey_patch "$BUILT_JSON"
;;
status)
build_json "survey_id" "${qtail[@]+"${qtail[@]}"}"
require_key survey_id "quant status requires --survey-id (numeric sid from quant list)"
invoke cookiy_quant_status "$BUILT_JSON"
;;
report)
build_json "survey_id" "${qtail[@]+"${qtail[@]}"}"
require_key survey_id "quant report requires --survey-id (numeric sid from quant list)"
invoke cookiy_quant_survey_report "$BUILT_JSON"
;;
raw-response|raw_response)
build_json "survey_id include_incomplete" "${qtail[@]+"${qtail[@]}"}"
require_key survey_id "quant raw-response requires --survey-id (numeric sid from quant list)"
# Dedicated raw-export tool: two parallel LS RPCs (export_responses CSV
# + list_participants), no aggregation. --include-incomplete maps 1:1.
rr_out="$(invoke cookiy_quant_survey_raw_responses "$BUILT_JSON")" || exit $?
echo "$rr_out" | jq -r '.raw_results.raw // empty'
;;
*)
die "quant list|create|get|update|status|report|raw-response"
;;
esac
;;
recruit)
sub="${TAIL[0]:-}"
rtail=("${TAIL[@]:1}")
case "$sub" in
start)
build_json "study_id confirmation_token plain_text incremental_participants survey_public_url" "${rtail[@]+"${rtail[@]}"}"
# --plain-text required on preview (step 1); step 2 only needs --confirmation-token
if ! echo "$BUILT_JSON" | grep -q '"confirmation_token"'; then
echo "$BUILT_JSON" | grep -q '"plain_text"' || die "recruit start: --plain-text is required"
fi
# Auto-detect quant mode when --survey-public-url is provided
if echo "$BUILT_JSON" | grep -q '"survey_public_url"'; then
json_set recruit_mode '"quant_survey"'
fi
# CLI policy: explicit reconfigure semantics (matches console UX). Live launch is enforced server-side on confirm.
json_set force_reconfigure true
invoke cookiy_recruit_create "$BUILT_JSON"
;;
*) die "recruit start" ;;
esac
;;
billing)
sub="${TAIL[0]:-}"
btail=("${TAIL[@]:1}")
case "$sub" in
balance)
[[ ${#btail[@]} -eq 0 ]] || die "billing balance takes no arguments"
invoke cookiy_balance_get '{}'
;;
checkout)
build_json "amount_usd_cents" "${btail[@]+"${btail[@]}"}"
require_key amount_cents "billing checkout requires --amount-usd-cents <integer>"
invoke cookiy_billing_cash_checkout "$BUILT_JSON"
;;
price-table)
[[ ${#btail[@]} -eq 0 ]] || die "billing price-table takes no arguments"
invoke cookiy_billing_price_table '{}'
;;
transactions)
build_json "limit cursor study_id survey_id" "${btail[@]+"${btail[@]}"}"
invoke cookiy_billing_transactions "$BUILT_JSON"
;;
*) die "billing balance|checkout|price-table|transactions" ;;
esac
;;
user)
sub="${TAIL[0]:-}"
utail=("${TAIL[@]:1}")
case "$sub" in
info)
[[ ${#utail[@]} -eq 0 ]] || die "user info takes no arguments"
invoke cookiy_user_info_get '{}'
;;
*) die "user info" ;;
esac
;;
*)
die "Unknown command: $CMD
$(usage)"
;;
esac