#!/usr/bin/env ruby # frozen_string_literal: true # launchtowin — the Launch to Win command line. # # One file, Ruby standard library only. Talks to the token API under /api on # launchto.win (or any other install via --server / LAUNCHTOWIN_URL) and gives a # maker, or an agent working for one, the whole package from the terminal: # create it, read and change its copy, manage the asset library, check it # against every directory's form, and pull a launch kit sized to one. A # launchtowin.json manifest in a repo names the package so no command needs a # slug there. Run `launchtowin help` for the reference and `launchtowin skill` # for the guide written for agents. # # Install: copy this file somewhere on PATH, e.g. # curl -fsSL https://launchto.win/cli/launchtowin -o ~/.local/bin/launchtowin && chmod +x ~/.local/bin/launchtowin # then `launchtowin login` with the API token from https://launchto.win/cli. require "json" require "net/http" require "uri" require "fileutils" require "io/console" require "securerandom" require "tmpdir" module LaunchToWin VERSION = "0.2.1" DEFAULT_URL = "https://launchto.win" SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ LIST_FIELDS = %w[platforms categories].freeze NUMBER_FIELDS = %w[price_amount].freeze ROLES = %w[logo screenshot video].freeze MANIFEST = "launchtowin.json" # Verbs that act on a package. With a manifest in reach they need no slug. PACKAGE_VERBS = %w[show create set get delete rm assets library directories dirs publish kit submitted submissions check open url].freeze # package field key => the field key directory maps use, where they differ. ISSUE_FIELDS = { "contact_email" => "email", "categories" => "category", "pricing_model" => "pricing", "price_amount" => "pricing", "price_period" => "pricing", "price_currency" => "pricing" }.freeze # Manifests, the config file, @file values and arguments are UTF-8 whatever # the shell's locale says. Under LC_ALL=C Ruby tags file contents US-ASCII # and arguments BINARY, and JSON then refuses any non-ASCII character. def self.read_text(path) = File.read(path, encoding: Encoding::UTF_8) def self.write_text(path, text, **options) = File.write(path, text, encoding: Encoding::UTF_8, **options) def self.text(string) = string.dup.force_encoding(Encoding::UTF_8) class Error < StandardError; end class UsageError < Error; end class ApiError < Error attr_reader :status, :body def initialize(status, body) @status = status @body = body super(describe) end def code body.is_a?(Hash) ? body["code"] : nil end private def describe return "HTTP #{status}" unless body.is_a?(Hash) lines = [ body["error"] || "HTTP #{status}" ] lines.concat(Array(body["details"]).map { |d| " - #{d}" }) %w[valid_pricing_models valid_categories].each do |key| lines << " valid: #{Array(body[key]).join(', ')}" if body[key] end lines.join("\n") end end # ~/.config/launchtowin/config.json: { "url": ..., "token": ... } module Config def self.path dir = ENV["LAUNCHTOWIN_CONFIG_DIR"] || File.join(ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config"), "launchtowin") File.join(dir, "config.json") end def self.read File.exist?(path) ? JSON.parse(LaunchToWin.read_text(path)) : {} rescue JSON::ParserError {} end def self.write(data) FileUtils.mkdir_p(File.dirname(path), mode: 0o700) LaunchToWin.write_text(path, JSON.pretty_generate(data) + "\n", perm: 0o600) File.chmod(0o600, path) end def self.delete File.delete(path) if File.exist?(path) end end # launchtowin.json in the current directory or a parent: which package this # repo is, plus (optionally) its fields and the asset files to publish. # # { "slug": "kode", # "package": { "name": "Kode", "tagline": "...", "description": "@docs/description.md", ... }, # "assets": [ { "path": "marketing/icon.png", "as": "logo", "tags": ["icon"], "title": "App icon" } ] } class Manifest attr_reader :path, :data # Walks up from `from`, stopping at the repository root (a .git entry) so # a manifest never comes from outside the project being worked on. def self.find(from = Dir.pwd) dir = File.expand_path(from) loop do candidate = File.join(dir, MANIFEST) return new(candidate) if File.file?(candidate) parent = File.dirname(dir) return nil if parent == dir || File.exist?(File.join(dir, ".git")) dir = parent end end def initialize(path, data = nil) @path = path @data = data || JSON.parse(LaunchToWin.read_text(path)) raise Error, "#{path} has no \"slug\"" if @data["slug"].to_s.empty? rescue JSON::ParserError => e raise Error, "#{path} is not valid JSON: #{e.message}" end def slug = data["slug"] def dir = File.dirname(path) def package = data["package"].is_a?(Hash) ? data["package"] : {} def assets = data["assets"].is_a?(Array) ? data["assets"] : [] # Field values with "@file" references read relative to the manifest. # Only files inside the manifest's folder qualify: a manifest in a cloned # repo must not be able to read anything else on the machine. def resolved_package package.transform_values do |value| next value unless value.is_a?(String) && value.start_with?("@") file = confine(value[1..]) File.file?(file) ? LaunchToWin.read_text(file).chomp : value end end # A path from the manifest, resolved and checked to lie inside its folder # (symlinks followed, no .. escapes, no absolute or ~ paths). def confine(relative) relative = relative.to_s raise Error, "#{relative.inspect} in #{path} must be a relative path inside #{dir}" if relative.empty? || relative.start_with?("/", "~") root = File.realpath(dir) full = File.expand_path(relative, root) real = File.exist?(full) ? File.realpath(full) : full unless real == root || real.start_with?(root + File::SEPARATOR) raise Error, "#{relative.inspect} in #{path} points outside #{dir}; a manifest may only use files in its own folder" end real end def save LaunchToWin.write_text(path, JSON.pretty_generate(data) + "\n") end end # Thin HTTP client: JSON in and out, multipart uploads, streamed downloads. class Client MIME = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml", ".ico" => "image/x-icon", ".mp4" => "video/mp4", ".mov" => "video/quicktime", ".webm" => "video/webm", ".m4v" => "video/x-m4v", ".pdf" => "application/pdf", ".txt" => "text/plain", ".md" => "text/markdown", ".zip" => "application/zip" }.freeze attr_reader :url def self.normalize_url(url) value = url.to_s.strip.sub(%r{/+\z}, "") value.match?(%r{\Ahttps?://}i) ? value : "https://#{value}" end def initialize(url:, token:) @url = self.class.normalize_url(url) @token = token end def get(path, params = nil) uri = build_uri(path) uri.query = URI.encode_www_form(params.compact) if params && !params.compact.empty? json(Net::HTTP::Get.new(uri), uri) end def post(path, body = nil) uri = build_uri(path) req = Net::HTTP::Post.new(uri) if body req["Content-Type"] = "application/json" req.body = JSON.generate(body) end json(req, uri) end def patch(path, body) uri = build_uri(path) req = Net::HTTP::Patch.new(uri) req["Content-Type"] = "application/json" req.body = JSON.generate(body) json(req, uri) end def delete(path) uri = build_uri(path) json(Net::HTTP::Delete.new(uri), uri) end # files: [[field_name, local_path], ...]; fields: { name => value or [values] } def upload(path, files:, fields: {}) uri = build_uri(path) boundary = "launchtowin-#{SecureRandom.hex(12)}" body = String.new(encoding: Encoding::BINARY) fields.each do |name, value| Array(value).each do |item| next if item.nil? body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{item}\r\n".b end end files.each do |name, file| body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"; filename=\"#{File.basename(file).gsub('"', '')}\"\r\n".b body << "Content-Type: #{MIME.fetch(File.extname(file).downcase, 'application/octet-stream')}\r\n\r\n".b body << File.binread(file) << "\r\n".b end body << "--#{boundary}--\r\n".b req = Net::HTTP::Post.new(uri) req["Content-Type"] = "multipart/form-data; boundary=#{boundary}" req.body = body json(req, uri) end # Streams a file (an asset or export URL from the API) to disk. def download(file_url, to, redirects: 5) uri = URI.parse(file_url) uri = build_uri(file_url) if uri.host.nil? http(uri) do |conn| req = Net::HTTP::Get.new(uri) req["Authorization"] = "Bearer #{@token}" if @token && same_host?(uri) conn.request(req) do |res| case res when Net::HTTPRedirection raise Error, "Too many redirects downloading #{file_url}" if redirects.zero? return download(URI.join(uri, res["location"]).to_s, to, redirects: redirects - 1) when Net::HTTPSuccess FileUtils.mkdir_p(File.dirname(to)) File.open(to, "wb") { |f| res.read_body { |chunk| f.write(chunk) } } else raise Error, "Download failed (HTTP #{res.code}) for #{file_url}" end end end to end private def build_uri(path) URI.parse(path.start_with?("http") ? path : "#{@url}#{path}") end def same_host?(uri) base = URI.parse(@url) uri.host == base.host && uri.port == base.port end def http(uri) Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 15, read_timeout: 180) { |conn| yield conn } rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e raise Error, "Can't reach #{uri.host}:#{uri.port} (#{e.class}). Is the server up? Set LAUNCHTOWIN_URL or --server for another install." end def json(req, uri) req["Authorization"] = "Bearer #{@token}" if @token req["Accept"] = "application/json" req["User-Agent"] = "launchtowin-cli/#{VERSION}" res = http(uri) { |conn| conn.request(req) } body = parse(res.body) raise ApiError.new(res.code.to_i, body) unless res.is_a?(Net::HTTPSuccess) body end def parse(text) return nil if text.nil? || text.strip.empty? JSON.parse(text) rescue JSON::ParserError { "error" => text.to_s[0, 300] } end end class CLI # client: inject a fake for tests; cwd: where to look for the manifest. def initialize(out: $stdout, err: $stderr, input: $stdin, client: nil, cwd: Dir.pwd, home: Dir.home) @out = out @err = err @in = input @client = client @cwd = cwd @home = home @json = false end def run(argv) argv = argv.map { |arg| LaunchToWin.text(arg) } globals = take_globals!(argv) @globals = globals @json = globals[:json] return (@out.puts("launchtowin #{VERSION}") ; 0) if globals[:version] return (@out.puts(help) ; 0) if globals[:help] || (argv.empty? && manifest.nil?) @client_url = globals[:server] || ENV["LAUNCHTOWIN_URL"] || Config.read["url"] || DEFAULT_URL @token = globals[:token] || ENV["LAUNCHTOWIN_TOKEN"] || Config.read["token"] command = argv.shift case command when nil then show(manifest.slug, create: false) when "help" then @out.puts(help) when "version" then @out.puts("launchtowin #{VERSION}") when "login" then login(argv) when "logout" then logout when "whoami" then whoami when "list", "ls", "packages" then list when "fields", "schema" then schema when "skill" then skill(argv) when "init" then init(argv) when "push" then push(argv) when "pull" then pull(argv) when "directories", "dirs" manifest ? package_command(manifest.slug, [ command, *argv ]) : directories(argv) else if PACKAGE_VERBS.include?(command) && manifest package_command(manifest.slug, [ command, *argv ]) elsif PACKAGE_VERBS.include?(command) raise UsageError, "No #{MANIFEST} here, so #{command} needs a package: launchtowin #{command} … (or run launchtowin init )." else package_command(command, argv) end end 0 rescue UsageError => e fail_with(e.message, 2) rescue ApiError => e fail_with(e.message, 1, e.body) rescue Error => e fail_with(e.message, 1) rescue Interrupt 130 end private # --- top-level commands --------------------------------------------------- def login(argv) opts, = options(argv, "--token" => :value, "--server" => :value, "--url" => :value) url = opts["--server"] || opts["--url"] || @globals[:server] || ENV["LAUNCHTOWIN_URL"] || DEFAULT_URL token = opts["--token"] || @globals[:token] || ENV["LAUNCHTOWIN_TOKEN"] token ||= prompt_token(url) raise UsageError, "No token given. Copy yours from #{url}/cli and run: launchtowin login --token " if token.to_s.strip.empty? @client_url = url @token = token.strip @client = nil me = client.get("/api/me") Config.write("url" => @client_url, "token" => @token) emit(me) { "Signed in to #{@client_url} as #{me['email']} (#{me.dig('plan', 'name')} plan). Token saved to #{Config.path}." } end def logout Config.delete emit("logged_out" => true) { "Signed out. Removed #{Config.path}." } end def whoami me = client.get("/api/me") emit(me) do [ "#{me['email']} (#{me['username']}) on #{@client_url}", "Plan: #{me.dig('plan', 'name')} — #{me['packages_count']} of #{me['package_limit']} packages used#{me['can_create_package'] ? '' : ' (limit reached)'}", "Press assets: #{me['press_assets'] ? 'yes' : 'no'}#{me['admin'] ? ' Admin: yes' : ''}" ] end end def list data = client.get("/api/packages") emit(data) do rows = data["packages"].map { |p| [ p["slug"], "#{p['completeness']}%", p["name"], p["url"].to_s, p["tagline"].to_s ] } rows.empty? ? [ "No packages yet. Create one: launchtowin create --name \"Product name\" --url https://..." ] : table(%w[SLUG DONE NAME URL TAGLINE], rows) end end def directories(argv) options(argv, {}) data = client.get("/api/directories") emit(data) do rows = data["directories"].map { |d| [ d["slug"], d["name"], d["pricing_tier"], d["rating"].to_s, d["account_required"] ? "account" : "", d["submission_url"] ] } table(%w[SLUG NAME PRICING RATING LOGIN SUBMIT], rows) end end def schema data = client.get("/api/schema") emit(data) do lines = [ "Package fields (launchtowin set key=value ...):" ] data["fields"].each do |f| limit = f["limit"] ? " (max #{f['limit']})" : "" values = f["values"] ? " [#{f['values'].join(', ')}]" : "" lines << format(" %-22s %s%s%s", f["key"], f["description"], limit, values) end lines << "" lines << "Per-directory copy fields (set --for ): #{data['directory_copy_fields'].join(', ')}" lines << "Pricing models: #{data['pricing_models'].join(', ')}" lines << "Platforms: #{data['platforms'].join(', ')}" lines << "Asset roles: #{data['asset_roles'].join(', ')} Upload limit: #{bytes(data['upload_limit_bytes'])} per file" lines << "Categories: #{data['categories'].map { |c| c['slug'] }.join(', ')}" lines end end # The guide for agents: print it, or install it where agents look. def skill(argv) opts, = options(argv, "--install" => :flag, "--force" => :flag, "--to" => :value) return @out.puts(SKILL_MD) unless opts["--install"] || opts["--to"] targets = [] targets << File.join(opts["--to"], "SKILL.md") if opts["--to"] if targets.empty? targets << File.join(@home, ".claude", "skills", "launchtowin", "SKILL.md") %w[.codex .cursor].each do |tool| targets << File.join(@home, tool, "skills", "launchtowin", "SKILL.md") if File.directory?(File.join(@home, tool)) end end results = targets.map do |target| if File.exist?(target) && File.binread(target) == SKILL_MD.b [ target, "up to date" ] elsif File.exist?(target) && !opts["--force"] [ target, "kept (differs; use --force to overwrite)" ] else FileUtils.mkdir_p(File.dirname(target)) LaunchToWin.write_text(target, SKILL_MD) [ target, "installed" ] end end emit("installed" => results.map { |t, s| { "path" => t, "status" => s } }) { results.map { |t, s| "#{s}: #{t}" } } end # --- manifest commands ------------------------------------------------------- def init(argv) opts, rest = options(argv, "--name" => :value, "--url" => :value) existing = manifest slug = rest.first || existing&.slug || File.basename(@cwd).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") raise UsageError, "#{slug.inspect} is not a slug (lowercase letters, digits and dashes). Usage: launchtowin init " unless slug.match?(SLUG_FORMAT) package = fetch_package(slug) created = false if package.nil? fields = { "name" => opts["--name"] || slug.split("-").map(&:capitalize).join(" ") } fields["url"] = opts["--url"] if opts["--url"] package = client.post("/api/packages", "package" => fields.merge("slug" => slug)) created = true end path = existing && existing.slug == slug ? existing.path : File.join(@cwd, MANIFEST) data = existing && existing.slug == slug ? existing.data : { "slug" => slug, "package" => {}, "assets" => [] } data["package"] = manifest_fields(package).merge(data["package"].is_a?(Hash) ? data["package"] : {}) data["assets"] ||= [] m = Manifest.new(path, data) m.save ignore = File.join(File.dirname(path), ".gitignore") ignored = File.exist?(ignore) ? File.readlines(ignore, chomp: true) : [] File.open(ignore, "a") { |f| f.puts "launchtowin/" } unless ignored.include?("launchtowin/") || ignored.include?("/launchtowin/") @manifest = m emit("manifest" => path, "slug" => slug, "created" => created) do [ "#{created ? 'Created' : 'Found'} package #{slug} (#{package['name']}).", "Wrote #{path} with its fields; launchtowin/ is gitignored for kits.", "Edit the manifest and run launchtowin push, or just: launchtowin set key=value" ] end end # Manifest → server: the fields, then every listed asset with its role. # Uploads dedupe on the server, so this is safe to run again and again. def push(argv) opts, = options(argv, "--dry-run" => :flag) m = manifest or raise UsageError, "No #{MANIFEST} here. Run launchtowin init first." fields = m.resolved_package if opts["--dry-run"] return emit("slug" => m.slug, "package" => fields, "assets" => m.assets) { [ "Would set: #{fields.keys.join(', ')}", "Would upload: #{m.assets.map { |a| a['path'] }.join(', ')}" ] } end # Every path is checked before anything is sent, so a bad manifest # changes nothing on the server. files = m.assets.map do |spec| file = m.confine(spec["path"]) raise Error, "Asset not found: #{file} (listed in #{m.path})" unless File.file?(file) [ spec, file ] end package = fields.empty? ? client.get("/api/packages/#{m.slug}") : client.patch("/api/packages/#{m.slug}", "package" => fields) uploaded = files.map do |spec, file| role = spec["as"] && normalize_role(spec["as"]) data = client.upload("/api/packages/#{m.slug}/assets", files: [ [ "files[]", file ] ], fields: { "as" => role, "title" => spec["title"], "tags" => Array(spec["tags"]).join(","), "notes" => spec["notes"] }.compact) data["assets"].first.merge("path" => spec["path"]) end check = client.get("/api/packages/#{m.slug}/check") emit("package" => package, "assets" => uploaded, "check" => check) do lines = [ "Pushed #{m.slug}: #{fields.keys.size} field(s), #{uploaded.size} asset(s)." ] uploaded.each { |a| lines << " #{a['existing'] ? 'kept' : 'added'} ##{a['id']} #{a['path']}#{a['roles'].any? ? " as #{a['roles'].join(', ')}" : ''}" } lines << "" lines.concat(describe_check(check, brief: true)) end end # Server → manifest: the stored field values, leaving the assets list alone. def pull(argv) options(argv, {}) m = manifest or raise UsageError, "No #{MANIFEST} here. Run launchtowin init first." package = client.get("/api/packages/#{m.slug}") m.data["package"] = manifest_fields(package) m.save emit("manifest" => m.path, "package" => m.data["package"]) { "Pulled #{m.slug} into #{m.path} (#{m.data['package'].keys.size} fields)." } end # --- package commands ----------------------------------------------------- def package_command(slug, argv) raise UsageError, "Unknown command #{slug.inspect} (and not a slug: use lowercase letters, digits and dashes). See launchtowin help." unless slug.match?(SLUG_FORMAT) verb = argv.shift # `launchtowin kode` shows the package, creating it when it is new. return show(slug, create: true) if verb.nil? verb = "publish" if verb == "--publish" case verb when "show" then show(slug, create: false) when "create" then create(slug, argv) when "set" then set(slug, argv) when "get" then get(slug, argv) when "check" then check(slug, argv) when "delete", "rm" then delete(slug, argv) when "assets", "library" then assets(slug, argv) when "directories", "dirs" then package_directories(slug, argv) when "publish", "kit" then publish(slug, argv) when "submitted" then submitted(slug, argv) when "submissions" then submissions(slug, argv) when "open", "url" then open_urls(slug) else raise UsageError, "Unknown subcommand #{verb.inspect} for #{slug}. See launchtowin help." end end def show(slug, create:) package = fetch_package(slug) if package.nil? raise ApiError.new(404, "code" => "not_found", "error" => "No package #{slug.inspect}. Create it: launchtowin #{slug} create --name \"Name\" --url https://...") unless create return create(slug, []) end emit(package) { describe_package(package) } end def create(slug, argv) opts, rest = options(argv, "--name" => :value, "--url" => :value, "--wait" => :flag) fields = pairs_to_fields(rest) fields["name"] ||= opts["--name"] || slug.split("-").map(&:capitalize).join(" ") fields["url"] ||= opts["--url"] if opts["--url"] package = client.post("/api/packages", "package" => fields.merge("slug" => slug)) package = wait_for_enrichment(slug) if opts["--wait"] && package.dig("meta", "enriching") emit(package) { [ "Created package #{package['slug']} (#{package['name']}).#{package.dig('meta', 'enriching') ? ' Launch to Win is reading its site in the background; add --wait to block until it is done.' : ''}", "" ] + describe_package(package) } end def set(slug, argv) opts, rest = options(argv, "--for" => :value, "--dry-run" => :flag) fields = pairs_to_fields(rest) raise UsageError, "Nothing to set. Usage: launchtowin #{slug} set key=value [key=@file ...] [--for ]" if fields.empty? body = if opts["--for"] bad = fields.keys - copy_fields raise UsageError, "With --for only directory copy fields can be set: #{copy_fields.join(', ')} (not #{bad.join(', ')})" if bad.any? { "description_overrides" => { opts["--for"] => fields } } else unknown = fields.keys - writable_keys raise UsageError, "Unknown field(s): #{unknown.join(', ')}. Run launchtowin fields for the list." if unknown.any? fields end return emit("slug" => slug, "package" => body) { "Would set #{fields.keys.join(', ')}#{opts['--for'] ? " for #{opts['--for']}" : ''} on #{slug}." } if opts["--dry-run"] package = client.patch("/api/packages/#{slug}", "package" => body) warnings = fit_warnings(slug, fields.keys, opts["--for"]) emit(package.merge("warnings" => warnings)) do lines = [ "Saved #{fields.keys.join(', ')}#{opts['--for'] ? " for #{opts['--for']}" : ''}.", "" ] + describe_package(package) lines += [ "", "Warnings:" ] + warnings.map { |w| " #{w}" } if warnings.any? lines end end def get(slug, argv) _, rest = options(argv, {}) package = client.get("/api/packages/#{slug}") path = rest.first return @out.puts(JSON.pretty_generate(package)) if path.nil? value = path.split(".").reduce(package) { |node, key| node.is_a?(Hash) ? node[key] : (node.is_a?(Array) && key.match?(/\A\d+\z/) ? node[key.to_i] : nil) } raise Error, "No #{path} on package #{slug}. Keys: #{package.keys.join(', ')}" if value.nil? && !package.key?(path.split(".").first) @out.puts(value.is_a?(String) ? value : JSON.pretty_generate(value)) end def check(slug, argv) options(argv, {}) data = client.get("/api/packages/#{slug}/check") emit(data) { describe_check(data) } end def delete(slug, argv) opts, = options(argv, "--yes" => :flag) confirm!("Delete package #{slug} and its asset library? This cannot be undone.", opts["--yes"]) data = client.delete("/api/packages/#{slug}") emit(data) { "Deleted package #{data['deleted']}." } end def open_urls(slug) package = client.get("/api/packages/#{slug}") emit(package["meta"]) { [ "Package: #{package.dig('meta', 'package_url')}", "Editor: #{package.dig('meta', 'editor_url')}" ] } end # --- assets --------------------------------------------------------------- def assets(slug, argv) verb = argv.first&.start_with?("--") ? "list" : (argv.shift || "list") case verb when "list", "ls" then assets_list(slug, argv) when "add", "upload" then assets_add(slug, argv) when "set" then assets_set(slug, argv) when "select", "use" then assets_select(slug, argv) when "remove", "rm", "delete" then assets_remove(slug, argv) when "download", "pull" then assets_download(slug, argv) else raise UsageError, "Unknown assets subcommand #{verb.inspect}: list, add, set, select, remove, download" end end def assets_list(slug, argv) opts, = options(argv, "--role" => :value, "--kind" => :value) data = client.get("/api/packages/#{slug}/assets") list = data["assets"] list = list.select { |a| a["roles"].include?(opts["--role"]) } if opts["--role"] list = list.select { |a| a["kind"] == opts["--kind"] } if opts["--kind"] emit(data.merge("assets" => list)) do next [ "The library is empty. Add files: launchtowin #{slug} assets add logo.png --as logo" ] if list.empty? rows = list.map do |a| [ a["id"], a["status"], a["kind"].to_s, bytes(a["byte_size"]), dims(a), a["roles"].join(","), a["title"].to_s, a["tags"].map { |t| "##{t}" }.join(" ") ] end table(%w[ID STATUS KIND SIZE DIMENSIONS ROLES TITLE TAGS], rows) + [ "", selection_line(data["selected"]) ] end end def assets_add(slug, argv) opts, files = options(argv, "--as" => :value, "--title" => :value, "--tags" => :value, "--notes" => :value, "--replace" => :value) raise UsageError, "Usage: launchtowin #{slug} assets add FILE... [--as logo|screenshot|video] [--title T] [--tags a,b] [--notes N] [--replace ID]" if files.empty? files.each { |f| raise Error, "No such file: #{f}" unless File.file?(f) } if opts["--replace"] raise UsageError, "--replace takes exactly one file" unless files.size == 1 data = client.upload("/api/packages/#{slug}/assets/#{opts['--replace']}/replace", files: [ [ "file", files.first ] ]) return emit(data) { [ "Replaced the file of ##{data.dig('asset', 'id')} #{data.dig('asset', 'title')}#{data.dig('asset', 'roles').to_a.any? ? " (still #{data['asset']['roles'].join(', ')})" : ''}", selection_line(data["selected"]) ] } end role = normalize_role(opts["--as"]) if opts["--as"] fields = { "as" => role, "title" => opts["--title"], "tags" => opts["--tags"], "notes" => opts["--notes"] }.compact data = client.upload("/api/packages/#{slug}/assets", files: files.map { |f| [ "files[]", f ] }, fields: fields) emit(data) do lines = data["assets"].map { |a| "#{a['existing'] ? 'Already there' : 'Added'} ##{a['id']} #{a['title']} (#{a['kind']}, #{bytes(a['byte_size'])})#{a['roles'].any? ? " as #{a['roles'].join(', ')}" : ''}" } lines << selection_line(data["selected"]) end end def assets_set(slug, argv) opts, rest = options(argv, "--title" => :value, "--tags" => :value, "--notes" => :value) id = rest.first or raise UsageError, "Usage: launchtowin #{slug} assets set ID [--title T] [--tags a,b] [--notes N]" body = { "title" => opts["--title"], "tags" => opts["--tags"], "notes" => opts["--notes"] }.compact raise UsageError, "Nothing to change: give --title, --tags or --notes" if body.empty? data = client.patch("/api/packages/#{slug}/assets/#{id}", body) emit(data) { "Saved ##{data.dig('asset', 'id')} #{data.dig('asset', 'title')}#{data.dig('asset', 'tags').to_a.any? ? ' ' + data['asset']['tags'].map { |t| "##{t}" }.join(' ') : ''}" } end def assets_select(slug, argv) opts, rest = options(argv, "--as" => :value) id = rest.first raise UsageError, "Usage: launchtowin #{slug} assets select ID --as logo|screenshot|video|none" if id.nil? || opts["--as"].nil? role = opts["--as"] == "none" ? "none" : normalize_role(opts["--as"]) data = client.post("/api/packages/#{slug}/assets/#{id}/select", "as" => role) emit(data) { [ "##{data.dig('asset', 'id')} #{data.dig('asset', 'title')} is now #{data.dig('asset', 'roles').to_a.any? ? data['asset']['roles'].join(' and ') : 'unselected'}.", selection_line(data["selected"]) ] } end def assets_remove(slug, argv) opts, rest = options(argv, "--yes" => :flag) id = rest.first or raise UsageError, "Usage: launchtowin #{slug} assets remove ID [--yes]" confirm!("Remove asset ##{id} from the library of #{slug}?", opts["--yes"]) data = client.delete("/api/packages/#{slug}/assets/#{id}") emit(data) { "Removed ##{data['deleted']}.#{data['kept_roles'].to_a.any? ? " It stays selected as #{data['kept_roles'].join(', ')}; pick another file to replace it." : ''}" } end def assets_download(slug, argv) opts, ids = options(argv, "--dir" => :value, "--role" => :value) data = client.get("/api/packages/#{slug}/assets") list = data["assets"].select { |a| a["url"] } list = list.select { |a| ids.include?(a["id"].to_s) } if ids.any? list = list.select { |a| a["roles"].include?(opts["--role"]) } if opts["--role"] raise Error, "Nothing to download#{ids.any? ? " for ids #{ids.join(', ')}" : ''}." if list.empty? dir = opts["--dir"] || work_dir(slug, "assets") saved = list.map do |a| name = "#{a['id']}-#{a['filename'] || 'file'}".gsub(%r{[/\\]}, "_") path = client.download(a["url"], File.join(dir, name)) a.merge("path" => path) end emit("assets" => saved) { saved.map { |a| "#{a['path']} (#{a['kind']}#{a['roles'].any? ? ', ' + a['roles'].join(',') : ''})" } } end # --- directories, kit and submissions -------------------------------------- def package_directories(slug, argv) opts, = options(argv, "--ready" => :flag, "--untracked" => :flag, "--tracked" => :flag) data = client.get("/api/packages/#{slug}/directories") list = data["directories"] list = list.select { |d| d.dig("fit", "status") == "ready" } if opts["--ready"] list = list.select { |d| d["submission"].nil? } if opts["--untracked"] list = list.select { |d| d["submission"] } if opts["--tracked"] emit(data.merge("directories" => list)) do rows = list.map { |d| [ d["slug"], d["name"], d.dig("fit", "status"), d.dig("submission", "status") || "-", d["account_required"] ? "account" : "", d["submission_url"] ] } table(%w[SLUG NAME FIT STATUS LOGIN SUBMIT], rows) + [ "", "Kit for one: launchtowin #{slug} publish " ] end end def publish(slug, argv) opts, rest = options(argv, "--dir" => :value, "--no-images" => :flag, "--no-track" => :flag, "--dry-run" => :flag) directory = rest.first kit = client.get("/api/packages/#{slug}/kit", "directory" => directory, "images" => (opts["--no-images"] ? nil : "1")) dir_slug = kit.dig("directory", "slug") || "generic" downloaded = opts["--no-images"] || opts["--dry-run"] ? {} : download_images(kit["images"], opts["--dir"] || work_dir(slug, dir_slug)) tracking = nil if kit["directory"] && !opts["--no-track"] && !opts["--dry-run"] tracking = client.post("/api/submissions", "slug" => slug, "directory" => dir_slug, "via" => "cli") end emit(kit.merge("downloaded" => downloaded, "tracking" => tracking)) { describe_kit(slug, kit, downloaded, tracking) } end def submitted(slug, argv) opts, rest = options(argv, "--url" => :value, "--listing" => :value) directory = rest.first or raise UsageError, "Usage: launchtowin #{slug} submitted [--url ]" data = client.post("/api/submissions/submitted", "slug" => slug, "directory" => directory, "listing_url" => opts["--url"] || opts["--listing"], "via" => "cli") emit(data) { "Marked #{slug} as #{data.dig('submission', 'status')} on #{data.dig('directory', 'name')}#{data.dig('submission', 'listing_url') ? " — listed at #{data['submission']['listing_url']}" : ''}. Track it: #{data.dig('submission', 'url')}" } end def submissions(slug, argv) options(argv, {}) data = client.get("/api/packages/#{slug}/directories") list = data["directories"].select { |d| d["submission"] } emit("submissions" => list) do next [ "No submissions yet. Start one: launchtowin #{slug} publish " ] if list.empty? rows = list.map { |d| s = d["submission"]; [ d["slug"], d["name"], s["status"], s["submitted_at"].to_s[0, 10], s["listing_url"].to_s ] } table(%w[DIRECTORY NAME STATUS SUBMITTED LISTING], rows) end end # --- rendering -------------------------------------------------------------- def describe_package(p) meta = p["meta"] || {} d = p["descriptions"] || {} pricing = p["pricing"] || {} lines = [] lines << "#{p['name']} (#{p['slug']}) #{p['url']}#{meta['enriching'] ? ' (reading the site…)' : ''}" lines << "Tagline: #{p['tagline'] || '-'}" lines << "Completeness: #{meta['completeness']}%#{missing_line(meta['missing'])}" lines << "Pricing: #{pricing['display'] || '-'} Platforms: #{join_or_dash(p['platforms'])} Categories: #{join_or_dash(p['categories'])}" lines << "Media: logo #{p['logo'] ? 'yes' : 'no'}, #{p['screenshots'].to_a.size} screenshot(s), video #{p['demo_video'] ? 'yes' : 'no'}, #{meta['library_assets']} file(s) in the library" lines << "Copy: short #{len(d['short'])}/200, medium #{len(d['medium'])}/600, full #{len(d['long'])}/10000 (as published; empty fields fall back)" lines << "Keywords: #{join_or_dash(p['keywords'])}" if p["keywords"].to_a.any? lines << "Maker: #{p.dig('maker', 'name') || '-'}#{p.dig('maker', 'bio') ? ' — ' + p['maker']['bio'] : ''}" lines << "Links: #{p['links'].map { |k, v| "#{k} #{v}" }.join(' ')}" if p["links"].is_a?(Hash) && p["links"].any? lines << "Overrides: #{p['description_overrides'].map { |dir, f| "#{dir} (#{f.keys.join(', ')})" }.join('; ')}" if p["description_overrides"].is_a?(Hash) && p["description_overrides"].any? lines << "Submissions: #{meta['submissions'].to_h.map { |s, n| "#{n} #{s}" }.join(', ').then { |s| s.empty? ? 'none' : s }}" lines << "Edit: #{meta['editor_url']}" lines << "Next: launchtowin #{p['slug']} check | get | set key=value | assets | directories | publish " lines end def describe_check(data, brief: false) lines = [] missing = Array(data["missing"]) lines << "Completeness: #{data['completeness']}%#{data['publishable'] ? '' : ' (not yet publishable)'}" if missing.any? lines << "Missing:" missing.each { |m| lines << " #{m['label']}#{m['required'] ? ' (required)' : ''}" } end problems = Array(data["directories"]).reject { |d| d["status"] == "ready" } summary = data["summary"] || {} lines << "Directories: #{summary['ready'].to_i} ready, #{summary['overflowing'].to_i} overflowing, #{summary['blocked'].to_i} blocked" problems = problems.first(8) if brief problems.each do |d| d["issues"].each { |i| lines << " #{d['name']}: #{issue_text(i)}" } end lines << " … run launchtowin check for all of them" if brief && Array(data["directories"]).count { |d| d["status"] != "ready" } > 8 lines end def issue_text(issue) if issue["kind"] == "over" "#{issue['label']} is #{issue['over_by']} char#{issue['over_by'] == 1 ? '' : 's'} over the #{issue['max']} limit (#{issue['length']})" else "#{issue['label']} is required and empty" end end # After a write: the directories whose form the fields just changed still # break, as one line each. Never fails the command. def fit_warnings(slug, changed_keys, directory) data = client.get("/api/packages/#{slug}/check") keys = changed_keys.map { |k| ISSUE_FIELDS.fetch(k, k) } Array(data["directories"]).flat_map do |d| next [] if directory && d["slug"] != directory d["issues"].select { |i| keys.include?(i["field"]) }.map { |i| "#{d['name']}: #{issue_text(i)}" } end rescue ApiError, Error [] end def describe_kit(slug, kit, downloaded, tracking) dir = kit["directory"] fit = kit["fit"] lines = [] if dir lines << "Launch kit: #{kit.dig('package', 'name')} → #{dir['name']} [#{fit['status']}#{fit['issues'].any? ? ': ' + fit['issues'].join('; ') : ''}]" lines << "Submit at: #{dir['submission_url']}#{dir['account_required'] ? " (account required#{dir['signup_url'] ? ', sign up: ' + dir['signup_url'] : ''})" : ''}" lines << "Waiting: #{dir['waiting_period']}" if dir["waiting_period"] lines << "Note: this directory has no tailored field map yet; these are the fields most forms ask for." unless dir["mapped"] else lines << "Launch kit: #{kit.dig('package', 'name')} (generic — every field, standard image sizes)" end lines << "" lines << "Fields (copy exactly; limits are the directory's):" kit["fields"].each do |f| limit = [] limit << "max #{f['max']}" if f["max"] limit << "required" if f["required"] limit << "#{f['over_by']} OVER" if f["over_by"].to_i.positive? label = "#{f['label']}#{limit.any? ? " (#{limit.join(', ')})" : ''}" value = f["present"] ? f["value"].to_s : "(empty)" lines << (value.include?("\n") ? " #{label}:\n#{value.gsub(/^/, ' ')}" : " #{label}: #{value}") end if downloaded.any? lines << "" lines << "Images (sized for #{dir ? dir['name'] : 'standard directories'}):" downloaded.each do |field, files| if files.is_a?(Hash) lines << " #{field}: #{files['error']}" else files.each { |f| lines << " #{field}: #{f['path']} (#{f['content_type']}, #{bytes(f['byte_size'])})" } end end end lines << "" if tracking lines << "Tracked on Launch to Win as #{tracking.dig('submission', 'status')}: #{tracking.dig('submission', 'url')}" lines << "When the form is in: launchtowin #{slug} submitted #{dir['slug']} --url " elsif kit["submission"] lines << "Submission: #{kit.dig('submission', 'status')} (#{kit.dig('submission', 'url')})" end lines end def download_images(images, dir) return {} unless images.is_a?(Hash) images.each_with_object({}) do |(field, files), acc| next acc[field] = files if files.is_a?(Hash) # { error: ... } acc[field] = files.each_with_index.map do |f, i| name = files.size > 1 ? "#{field}-#{i + 1}-#{f['filename']}" : "#{field}-#{f['filename']}" f.merge("path" => client.download(f["url"], File.join(dir, name))) end end end def missing_line(missing) return "" unless missing.is_a?(Array) && missing.any? " missing: " + missing.map { |m| m["label"] + (m["required"] ? "*" : "") }.join(", ") end def selection_line(selected) return "" unless selected.is_a?(Hash) "Selected: logo ##{selected['logo'] || '-'}, screenshots #{selected['screenshots'].to_a.map { |i| "##{i}" }.join(' ').then { |s| s.empty? ? '-' : s }}, video ##{selected['video'] || '-'}" end # --- helpers ------------------------------------------------------------------ def client @client ||= begin raise Error, "Not signed in. Run: launchtowin login --token (token at #{@client_url}/cli), or set LAUNCHTOWIN_TOKEN." if @token.to_s.empty? Client.new(url: @client_url, token: @token) end end def manifest return @manifest if defined?(@manifest) @manifest = Manifest.find(@cwd) end # Where kits and downloads land: next to the manifest when there is one # (gitignored by init), else a temp dir. def work_dir(slug, sub) manifest ? File.join(manifest.dir, "launchtowin", sub) : File.join(Dir.tmpdir, "launchtowin", slug, sub) end # The package document's stored fields, as the manifest keeps them. def manifest_fields(package) fields = package["fields"].is_a?(Hash) ? package["fields"] : {} fields.reject { |_, v| v.nil? || v == "" || v == [] || v == {} } end def fetch_package(slug) client.get("/api/packages/#{slug}") rescue ApiError => e raise unless e.status == 404 nil end # Polls until the background read of the site is done (at most 90s). def wait_for_enrichment(slug) package = nil 18.times do sleep 5 package = client.get("/api/packages/#{slug}") break unless package.dig("meta", "enriching") end package end def schema_data @schema_data ||= client.get("/api/schema") end def writable_keys schema_data["fields"].map { |f| f["key"] } end def copy_fields schema_data["directory_copy_fields"] end # key=value pairs → { key => value }; value "@path" reads the file; # list fields split on commas, number fields become numbers. def pairs_to_fields(pairs) pairs.each_with_object({}) do |pair, fields| key, value = pair.split("=", 2) raise UsageError, "Expected key=value, got #{pair.inspect}" if value.nil? || key.to_s.empty? value = LaunchToWin.read_text(value[1..]).chomp if value.start_with?("@") && File.file?(value[1..]) value = value.split(",").map(&:strip).reject(&:empty?) if LIST_FIELDS.include?(key) value = (value.include?(".") ? value.to_f : value.to_i) if NUMBER_FIELDS.include?(key) && value.match?(/\A-?\d+(\.\d+)?\z/) fields[key] = value end end def normalize_role(role) r = role.to_s.downcase.sub(/s\z/, "") raise UsageError, "Role must be one of #{ROLES.join(', ')} (got #{role.inspect})" unless ROLES.include?(r) r end def take_globals!(argv) g = { json: false } i = 0 while i < argv.length a = argv[i] case a when "--json" then g[:json] = true; argv.delete_at(i); next when "--help", "-h" then g[:help] = true; argv.delete_at(i); next when "--version", "-v" then g[:version] = true; argv.delete_at(i); next when "--server", "--token" raise UsageError, "#{a} needs a value" if argv[i + 1].nil? g[a.delete_prefix("--").to_sym] = argv[i + 1] argv.slice!(i, 2); next when /\A--(server|token)=(.+)\z/ then g[$1.to_sym] = $2; argv.delete_at(i); next end i += 1 end g end # spec: { "--name" => :value, "--yes" => :flag }. Returns [options, positionals]. def options(argv, spec) opts = {} rest = [] args = argv.dup until args.empty? a = args.shift if a == "--" rest.concat(args) break elsif a.start_with?("--") name, inline = a.split("=", 2) kind = spec[name] or raise UsageError, "Unknown option #{name}. See launchtowin help." if kind == :flag opts[name] = true else value = inline || args.shift raise UsageError, "#{name} needs a value" if value.nil? opts[name] = value end else rest << a end end [ opts, rest ] end def prompt_token(url) if @in.respond_to?(:tty?) && @in.tty? @err.print "API token (copy it from #{url}/cli): " token = @in.respond_to?(:noecho) ? @in.noecho(&:gets) : @in.gets @err.puts token else @in.gets end end def confirm!(question, yes) return if yes raise UsageError, "#{question} Re-run with --yes to confirm." unless @in.respond_to?(:tty?) && @in.tty? @err.print "#{question} [y/N] " raise Error, "Cancelled." unless @in.gets.to_s.strip.downcase.start_with?("y") end def emit(data) if @json @out.puts(JSON.pretty_generate(data)) else text = yield @out.puts(Array(text).join("\n")) end end def fail_with(message, code, body = nil) if @json @err.puts(JSON.generate(body.is_a?(Hash) ? body : { "code" => (code == 2 ? "usage" : "error"), "error" => message })) else @err.puts("launchtowin: #{message}") end code end def table(headers, rows) rows = rows.map { |r| r.map { |c| c.to_s.gsub(/\s+/, " ") } } widths = headers.each_index.map { |i| ([ headers[i] ] + rows.map { |r| r[i].to_s }).map(&:length).max } line = ->(cells) { cells.each_with_index.map { |c, i| i == cells.size - 1 ? c.to_s : c.to_s.ljust(widths[i]) }.join(" ").rstrip } [ line.call(headers) ] + rows.map { |r| line.call(r) } end def bytes(n) return "-" if n.nil? n = n.to_f return "#{n.to_i} B" if n < 1024 return format("%.0f KB", n / 1024) if n < 1024 * 1024 format("%.1f MB", n / 1024 / 1024) end def dims(a) a["width"] && a["height"] ? "#{a['width'].round}x#{a['height'].round}" : "" end def len(s) s.to_s.length end def join_or_dash(list) items = Array(list).reject { |x| x.to_s.empty? } items.empty? ? "-" : items.join(", ") end def help <<~HELP launchtowin #{VERSION} — your launch packages on Launch to Win from the terminal. Launch to Win is the data hub for a product's launch: one package holding the name, tagline, descriptions in three lengths, pricing, categories, links, and a library of logos, screenshots and videos. This CLI reads and writes that package so you, or an agent working for you, can keep it current and pull everything needed to publish the product on any directory or site. SETUP launchtowin login [--token T] [--server U] save your API token (from #{DEFAULT_URL}/cli) launchtowin whoami who the token belongs to, plan and limits launchtowin logout forget the token launchtowin skill [--install] [--force] the guide for AI agents; --install puts it where Claude Code, Codex and Cursor look Env: LAUNCHTOWIN_TOKEN, LAUNCHTOWIN_URL override the saved config. Global flags: --json, --server, --token. IN A REPO (launchtowin.json names the package, so no slug is needed there) launchtowin init [] [--name N] [--url U] create the manifest (and the package if new); gitignores launchtowin/ launchtowin push [--dry-run] sync the manifest's fields and asset files to Launch to Win; safe to repeat launchtowin pull write the stored fields back into the manifest launchtowin set key=value ... / check / assets ... / publish / submitted ... all work without a slug PACKAGES launchtowin list your packages launchtowin show the package; creates it when it doesn't exist launchtowin create [--name N] [--url U] [--wait] [key=value ...] --wait blocks until the site has been read launchtowin check what is missing, and every directory whose form the copy would not pass launchtowin get [path] the full package JSON, or one value (tagline, descriptions.long, fields.description) launchtowin set key=value ... change fields; value @file reads a file; lists are comma-separated; warns on overflow launchtowin set --for tagline="..." description="..." copy for one directory only launchtowin delete [--yes] launchtowin open the package's URLs on the site launchtowin fields every field with its limit, pricing models, platforms, categories Fields: name url tagline description_short(200) description_medium(600) description(10000) keywords contact_email alternative_to maker_name maker_bio launch_date pricing_model price_amount price_currency price_period platforms categories open_source twitter_url github_url linkedin_url facebook_url instagram_url youtube_url. Example: launchtowin kode set tagline="Passwords, done right" pricing_model=free platforms=ios,mac categories=utilities description=@README.md ASSETS (the package's file library: originals stay, roles decide what gets published) launchtowin assets list files with id, status, size, dimensions and roles launchtowin assets add FILE... [--as logo|screenshot|video] [--title T] [--tags a,b] [--notes N] the same bytes twice return the existing entry, so re-running is safe launchtowin assets add FILE --replace ID new bytes, same title, tags and roles launchtowin assets select ID --as logo|screenshot|video|none launchtowin assets set ID [--title T] [--tags a,b] [--notes N] launchtowin assets remove ID [--yes] launchtowin assets download [ID...] [--role logo] [--dir DIR] originals to disk PUBLISHING launchtowin directories every directory Launch to Win knows launchtowin directories [--ready] [--untracked] [--tracked] fit and submission state per directory launchtowin publish [] [--dir DIR] [--no-images] [--no-track] [--dry-run] The launch kit: copy-ready fields trimmed to that directory's limits, its submission link, and the logo/screenshots exported to its image requirements, saved to disk (./launchtowin// in a repo with a manifest, else a temp dir). Marks the submission as in progress. Without a directory you get the generic kit. is a slug, a host (producthunt.com) or a submission URL. launchtowin submitted [--url ] mark it submitted after the form is in launchtowin submissions where the package has been submitted FOR AGENTS Add --json to any command for machine-readable output. Errors go to stderr as JSON with a code (not_found, unauthorized, limit_reached, validation, url_locked, unknown_directory, bad_role, ...). Typical flow to publish a product somewhere: 1. launchtowin --json read the package (descriptions, pricing, links, media URLs) 2. launchtowin publish --json --dir ./kit fields sized to the form + image files on disk 3. fill the site's form from the kit, upload the files, submit 4. launchtowin submitted --url To store new material: launchtowin assets add path/to/file --as screenshot --tags "ios,dark" To fix copy: launchtowin set description_short="..." then launchtowin check Exit codes: 0 ok, 1 error (API or network), 2 usage. HELP end # The agent guide, identical to cli/SKILL.md in the repository (a test # keeps them in sync) and to https://launchto.win/cli/SKILL.md. # SKILL_MD_BEGIN (generated from cli/SKILL.md by bin/rails cli:sync_skill; edit the .md, not this) SKILL_MD = <<~'MD' --- name: launchtowin description: Use Launch to Win (launchto.win) as the data hub for a product launch through the `launchtowin` CLI. Read a product's launch package, change its descriptions, add images and videos to its asset library, and pull a launch kit (copy sized to a directory's form plus exported images) when publishing the product on a directory or website. Trigger on "launchtowin", "Launch to Win", "launch package", "add this screenshot/video to the package", "publish on ", or when a site's submission form asks for the product's name, tagline, description, logo or screenshots. --- # launchtowin — the Launch to Win CLI `launchtowin` is a command on PATH (install: `curl -fsSL https://launchto.win/cli/launchtowin -o ~/.local/bin/launchtowin && chmod +x ~/.local/bin/launchtowin`). It uses the API token the user saved with `launchtowin login`. If `launchtowin whoami` reports no token, ask the user to run `launchtowin login` (the token is on https://launchto.win/cli); never ask for or handle the token yourself. Every command takes `--json` and prints the API response unchanged. Prefer it when you consume the output. Errors with `--json` are JSON on stderr with a `code` (`not_found`, `unauthorized`, `limit_reached`, `validation`, `url_locked`, `unknown_directory`, `bad_role`, ...). Exit codes: 0 ok, 1 API or network error, 2 usage. `launchtowin help` is the full reference. ## Which package A repo can hold `launchtowin.json` (created by `launchtowin init `). Inside that repo every command works without a slug: `launchtowin set …`, `launchtowin publish …`. Elsewhere name the package: `launchtowin …`. `launchtowin list` shows every slug. A bare `launchtowin ` creates the package when it does not exist, so use `launchtowin show` or `list` when you only mean to read. ## Read ```bash launchtowin list launchtowin --json # descriptions (short/medium/long), pricing, categories, links, media URLs, meta.missing launchtowin get descriptions.long # one value, raw launchtowin check # what is missing, and every directory whose form the copy would not pass ``` ## Write ```bash launchtowin set tagline="…" description_short="…" description_medium="…" description=@docs/description.md launchtowin set pricing_model=free platforms=ios,mac categories=utilities keywords="a,b" launchtowin set --for product-hunt tagline="…" # copy for one directory only launchtowin fields # every key, its limit and allowed values launchtowin push # sync launchtowin.json (fields and listed asset files) to the server launchtowin pull # write the server's package back into launchtowin.json ``` Limits: short 200, medium 600, full 10,000 characters. `set` prints warnings for directories whose limits the new copy breaks; shorten and set again. ## Assets ```bash launchtowin assets # library with ids, roles, tags launchtowin assets add shot.png --as screenshot --tags "ios,dark" --title "Home" launchtowin assets add icon.png --as logo launchtowin assets add demo.mp4 --as video launchtowin assets add new.png --replace 42 # new bytes, same title, tags and roles launchtowin assets download --role screenshot --dir ./shots launchtowin assets select 42 --as none # keep the file, drop the role ``` Uploads are idempotent: the same bytes twice return the existing entry. Originals stay in the library; roles (one logo, many screenshots, one video) decide what the package publishes. Add every format and size with tags, select the ones to publish. ## Publish on a directory or site ```bash launchtowin directories --ready # where it fits, with submission URLs launchtowin publish product-hunt --json --dir ./kit # fields trimmed to that form + logo/screenshots exported to its sizes # fill the site's form from the kit, upload the files from ./kit, let the user press submit launchtowin submitted product-hunt --url https://www.producthunt.com/posts/ ``` `publish` marks the submission as in progress. A directory is named by slug, host (`producthunt.com`) or submission URL; without one you get the generic kit (every field, standard sizes) for any site, with `--no-track`. ## Rules - Never print, log or commit the API token (`~/.config/launchtowin/config.json`). - Do not delete packages or assets unless the user asked; `delete` and `remove` need `--yes`. - Submitting a form on a directory is the user's decision; prepare it, then hand over. MD # SKILL_MD_END end end exit LaunchToWin::CLI.new.run(ARGV) if $PROGRAM_NAME == __FILE__