Run Perl Clinic from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page scans the code for free - every legacy idiom and real hazard the source skills name (the preamble, @_ unpacking, two-argument open, string eval, SQL built by interpolation, a .t without done_testing, and more), each with its rule id, line and fix, gated by the Perl version - in perlscan.js. The run writes the review and the rewrite, or the test file; the page then holds the reply to its own scan and re-scans what came back (perlreply.js).
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"perl-clinic"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a field the run needs (task, code, version, facts) is missing or not a string. Send every field as a string, version included ("5.36", not 5.36). |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes - review (judge the code: a verdict, findings with fixes, a call on every scan finding, and the complete rewritten file) and tests (write the complete Test2::V0 .t file for the code as it is, repairing and extending the one you sent). A missing or unknown task is answered as review, with a caveat saying so. Both lanes send the same fields.
The input
Every field is a string.
| Field | Required | What |
|---|---|---|
task | yes | review or tests. The model answers that task and only that task. |
code | yes | the module or script. The page clips a long file (over 24,000 characters) by keeping the head and the tail and putting a # ... N lines (a-b) omitted by Perl Clinic ... line in the middle; the model keeps that line and will not guess what was left out. |
tests | yes (may be "") | the existing .t file, or empty when there is none (clipped the same way past 12,000 characters). |
version | yes | the Perl the code must run on, for example "5.36", "5.40", "5.26". Nothing the version lacks is ever recommended - on an older Perl the answer is code that runs there plus "on 5.36+ this becomes ...". |
facts | yes | the page's own scan of the same code: the version, a prescan verdict, the code's shape (packages, public and private subs, exports, Moo attributes), which public subs the .t calls, and every finding as rule | severity | file | line | text. See the facts format. |
focus | no | what the developer wants weighed, for example "a bad line should be skipped, not fatal". May be empty; when given, the summary says how it changed the answer. |
retry_note | no | present only on a reformat retry: the previous reply could not be read, and this says how to answer again. Leave it out otherwise. |
The facts format
facts is the page's scan, written as plain text. An API caller may send their own short summary of the code instead, but the scan's format gets the most out of a run: the review answers every error and warn PL- rule id it finds there (one prescan entry per id), both lanes keep every public sub listed under "Shape", and the tests lane covers every sub listed as never called. The format, line by line:
Perl version: 5.<minor>.
Prescan verdict: <ship|refactor|rework> (<n> errors, <n> warnings, <n> notes).
Shape: <module|script|code>; package <names>; public subs <a, b>; private subs <_c>; exports <names>; attributes <names>.
Tests: the .t calls <n> of <m> public subs; never called: <a, b>. (or: Tests: no .t file was given.)
Clipped: <only when code or tests were clipped>
Findings (rule | severity | file | line | text):
- <rule id> | <error|warn|info> | <code|tests> | line <n> | <title> (<optional note>) | <the source line>
A list with nothing in it reads (none), and a clean scan has the single finding line - none. A real example - the facts the page sends for the sample used in the worked examples below:
Perl version: 5.38.
Prescan verdict: refactor (0 errors, 2 warnings, 2 notes).
Shape: module; package Ledger::Entry; public subs signed_amount, from_line, load_file, balance; private subs (none); exports (none); attributes account, amount, kind, memo.
Tests: the .t calls 2 of 4 public subs; never called: from_line, load_file.
Findings (rule | severity | file | line | text):
- PL-ATARGS | warn | code | line 17 | @_ unpacking instead of a signature | my ($class, $line, %opt) = @_;
- PL-RETUNDEF | warn | code | line 18 | return undef | return undef unless $line =~ /^(\d{4}-\d{2}-\d{2})\s+(\S+)\s+(-?[\d.]+)\s*(.*)$/;
- PL-POSCAP | info | code | line 19 | Positional captures (4 places in the file.) | my $kind = $3 < 0 ? 'credit' : 'debit';
- PL-EVALIF | info | code | line 27 | eval { } then if ($@) | my $entry = eval { $class->from_line($line) };
Rule ids the scan uses, for the code: PL-NOSTRICT (No strict, error), PL-NOWARN (No warnings, error), PL-BOILER (Legacy preamble, warn), PL-ATARGS (@_ unpacking instead of a signature, warn), PL-PROTO (Prototype, info), PL-RETUNDEF (return undef, warn), PL-OPEN2 (Two-argument open, error), PL-BAREFH (Bareword filehandle, warn), PL-OPENDIE (open not checked, warn), PL-INDIRECT (Indirect object syntax, warn), PL-STREVAL (String eval, error), PL-NOSTRICTREFS (no strict 'refs', warn), PL-SYMREF (Symbolic reference, warn), PL-DBI (SQL built by interpolation, error), PL-OURVAR (Mutable global as configuration, info), PL-CIRCUMFIX (Circumfix dereference, info), PL-POSCAP (Positional captures, info), PL-EVALIF (eval { } then if ($@), info), PL-BLESS (Blessed-hashref class, info), PL-BLESSEDISA (blessed($o) && $o->isa(...), info), PL-ISAEXPORTER (Exporter through @ISA, info), PL-EXPORTALL (@EXPORT (exports by default), warn), PL-TRUE (Module does not end in 1;, error), PL-EACH (each, info), PL-TRYEARLY (Native try/catch before 5.40, error), PL-CLASSEARLY (class keyword before 5.38, error), PL-SAYNOFEAT (say without the feature, error). For the .t file: PT-NODONE (No done_testing (or plan), error), PT-TESTMORE (Test::More, info), PT-ISDEEPLY (is_deeply, info), PT-EVALAT (eval { } and $@ in a test, warn), PT-MONKEY (Monkey-patched sub in a test, error), PT-OUR (our variable in a test, warn), PT-NOSUBTEST (No subtests, info), PT-PLAN (Fixed plan, info). Each rule is gated by the version (the PL-BOILER and PL-ATARGS advice needs 5.36, PL-CIRCUMFIX 5.24; PL-TRUE is not reported from 5.38 on). The review answers the PL- ids; the PT- ids belong to the tests lane. A finding the scan cannot see comes back with the rule NEW.
The standard the model applies
The source skills, @affaan-m/perl-patterns and @affaan-m/perl-testing, are the only standard:
- Respect the version:
use v5.36(strict, warnings,say, signatures) needs 5.36; postfix dereference 5.24;isais stable from 5.36; nativetry/catchfrom 5.40; theclasskeyword needs 5.38; fromuse v5.38a module no longer needs a final1;. - One
use v5.36;instead ofuse strict; use warnings; use feature .... Subs take signatures, notmy (...) = @_orshift; "nothing" is a barereturn;, neverreturn undef;. - Files: three-argument
openwith a lexical handle and an encoding layer, checked withor die "...: $!"oruse autodie;. Objects: Moo with typed attributes,Class->new(...), nevernew Class(...). - Modules:
use Exporter 'import'; our @EXPORT_OK = qw(...);,use constantrather than a mutableourglobal. Errors:Try::Tinyor nativetry/catchon 5.40; never a string eval - load modules by name withModule::Runtime; never symbolic references - use a hash. - Regexes use named captures with
/x, precompiled withqr//when reused. Databases use placeholders, never a value interpolated into SQL. - Tests:
Test2::V0(is,like,hash { ... },array { ... },dies { },lives { }), onesubtestper behaviour with its own setup,done_testing;at the end,Test::MockModulefor mocks,File::Tempfor files, in-memory SQLite for DBI. Run withprove -l.
House rules
- Every statement is grounded in the pasted code; no sub, attribute, module or file that is not in the input is invented. What the answer depends on but could not see (a class it uses, a fixture, the schema) goes in
caveats, as does a clipped gap. - The public API is kept exactly: the package name, every public sub in the facts (a sub may become a Moo attribute of the same name) and every exported name. Private subs (leading underscore) may change.
- In
review, everyerrorandwarnPL-finding is addressed by its rule id; a finding the model thinks is wrong is disputed with a reason, never silently dropped. - No claim that anything was run, compiled or tested - "run
prove -l", not "this passes". - Plain text in strings; code goes in the code fields as plain source with
\nnewlines and no Markdown fences. The reply is ONE JSON object and nothing else.
The replies
review
verdict is ship (modern and correct for its version), refactor (works, with legacy idioms or one real defect) or rework (unsafe or wrong, or legacy throughout). A scan error the model agrees with rules out ship.
summary- two or three sentences: what the code does, the verdict and the first thing to fix.findings- most severe first:rule(the facts' rule id, such asPL-OPEN2, orNEW),severity(high,mediumorlow),title,where(such asline 18),why(one or two sentences about this code),fix(the corrected lines).prescan- one entry pererrorandwarnPL-rule id in the facts:rule,call(agreeordisagree),note(one sentence).PT-findings belong to the tests lane.rewrite- the complete rewritten module or script for the stated version:name(such asReport.pm) andcode(the whole file, including what did not change).kept- anything deliberately left as it was:rule,what,why.caveats- what could not be seen or verified.
{
"task": "review",
"verdict": "refactor",
"summary": "",
"findings": [ { "rule": "PL-OPEN2", "severity": "high", "title": "", "where": "line 18", "why": "", "fix": "" } ],
"prescan": [ { "rule": "PL-OPEN2", "call": "agree", "note": "" } ],
"rewrite": { "name": "Report.pm", "code": "" },
"kept": [ { "rule": "", "what": "", "why": "" } ],
"caveats": [ "" ]
}
The page holds it to its own scan: every scan error and warning answered by rule id, no ship over an undisputed error, no rule id the scan did not report. It re-scans the rewrite: a legacy pattern still present and not listed in kept, a new problem introduced, or a package, public sub or export that disappeared is named. It shows the diff and offers a .patch.
tests
The complete test file for the code as it is (not a rewrite), in Test2::V0, for the stated version. When a .t was sent, it is repaired and extended: its working assertions kept, its PT- findings fixed, and every public sub the facts list as never called covered.
summary- two sentences: what the file covers and what it does not.file-name(such ast/inventory-report.t) andcode, the whole .t ending indone_testing;, with fixture data inline (a heredoc into a temp file).covers- one entry per public sub:sub(its exact name) andcases(the behaviours asserted, as short phrases).skipped- subs not tested, each withsubandwhy. Every public sub in the facts appears incoversor here.bugs- behaviours the tests will expose as failing against the code as written:sub,what. Empty when none.run- the command, such asprove -lv t/inventory-report.t.caveats- what could not be seen (fixtures, classes the code uses).
{
"task": "tests",
"summary": "",
"file": { "name": "t/report.t", "code": "" },
"covers": [ { "sub": "load_stock", "cases": [ "" ] } ],
"skipped": [ { "sub": "", "why": "" } ],
"bugs": [ { "sub": "", "what": "" } ],
"run": "prove -lv t/report.t",
"caveats": [ "" ]
}
The page re-scans the returned file: it must use Test2::V0 and end in done_testing, carry no remaining PT- error or warning, call no sub the module does not define, and actually call every sub it claims in covers. It reports which public subs are newly exercised.
Worked examples - both lanes, one module
One half-updated module (a Perl 5.38 Ledger::Entry Moo class with its .t, two scan warnings and two notes, and a focus on bank exports), sent once to each lane. Each input is exactly what the page sends; each reply is a real reply, abbreviated for reading: long strings end in ..., the code fields show only their first lines, and long lists are cut with a ... entry. The real replies are complete JSON with every field in full.
Ledger::Entry (Perl 5.38) - review
Input:
{
"task": "review",
"code": "package Ledger::Entry;\nuse v5.36;\nuse Moo;\nuse Types::Standard qw(Str Num Enum);\nuse namespace::autoclean;\n\nhas account => (is => 'ro', isa => Str, required => 1);\nhas amount => (is => 'ro', isa => Num, required => 1);\nhas kind => (is => 'ro', isa => Enum[qw(debit credit)], default => 'debit');\nhas memo => (is => 'ro', isa => Str, default => '');\n\nsub signed_amount($self) {\n return $self->kind eq 'credit' ? $self->amount : -$self->amount;\n}\n\nsub from_line {\n my ($class, $line, %opt) = @_;\n return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n my $kind = $3 < 0 ? 'credit' : 'debit';\n return $class->new(account => $2, amount => abs($3), kind => $kind, memo => $4 // $opt{memo} // '');\n}\n\nsub load_file($class, $path) {\n open my $fh, '<', $path or die \"Cannot open $path: $!\";\n my @entries;\n while (my $line = <$fh>) {\n my $entry = eval { $class->from_line($line) };\n if ($@) { warn \"skipped: $@\"; next }\n push @entries, $entry if $entry;\n }\n return @entries;\n}\n\nsub balance($class, @entries) {\n my $sum = 0;\n $sum += $_->signed_amount for @entries;\n return sprintf('%.2f', $sum);\n}\n\n1;",
"tests": "use v5.36;\nuse Test2::V0;\nuse Ledger::Entry;\n\nsubtest 'signed amounts' => sub {\n my $d = Ledger::Entry->new(account => 'cash', amount => 10);\n my $c = Ledger::Entry->new(account => 'cash', amount => 4, kind => 'credit');\n is($d->signed_amount, -10, 'debit is negative');\n is($c->signed_amount, 4, 'credit is positive');\n};\n\nsubtest 'balance' => sub {\n my @e = map { Ledger::Entry->new(account => 'cash', amount => $_) } 1, 2;\n is(Ledger::Entry->balance(@e), '-3.00', 'sums debits');\n};\n\ndone_testing;",
"version": "5.38",
"facts": "Perl version: 5.38.\nPrescan verdict: refactor (0 errors, 2 warnings, 2 notes).\nShape: module; package Ledger::Entry; public subs signed_amount, from_line, load_file, balance; private subs (none); exports (none); attributes account, amount, kind, memo.\nTests: the .t calls 2 of 4 public subs; never called: from_line, load_file.\nFindings (rule | severity | file | line | text):\n- PL-ATARGS | warn | code | line 17 | @_ unpacking instead of a signature | my ($class, $line, %opt) = @_;\n- PL-RETUNDEF | warn | code | line 18 | return undef | return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n- PL-POSCAP | info | code | line 19 | Positional captures (4 places in the file.) | my $kind = $3 < 0 ? 'credit' : 'debit';\n- PL-EVALIF | info | code | line 27 | eval { } then if ($@) | my $entry = eval { $class->from_line($line) };",
"focus": "Entries come from bank exports; a bad line should be skipped, not fatal."
}
Reply (abbreviated). The prescan said refactor and the review kept it, agreeing with all four scan findings; weighing the focus on bank exports, it led with six NEW findings the scan cannot see (malformed amounts read as wrong numbers, an unused memo option, a BOM that drops the first line) and returned the full rewritten Ledger/Entry.pm:
{
"task": "review",
"verdict": "refactor",
"summary": "Ledger::Entry is a typed Moo class that parses dated bank-export lines into debit/credit entries, loads them from a file and sums a balance; it is modern apart from from_line, so the verdict is refactor. Because of the focus on bank exports, the first fix is ...",
"findings": [
{
"rule": "NEW",
"severity": "high",
"title": "Malformed amounts are accepted as wrong numbers",
"where": "line 18-20",
"why": "(-?[\\d.]+)\\s*(.*) matches \"1.2.3\" (abs gives 1.2 with an 'isn't numeric' warning), \".\" (amount 0) and, for \"1,234.56\", captures 1 as the ...",
"fix": "(?<amount> -? (?: \\d+ (?: \\. \\d* )? | \\. \\d+ ) )\n(?: \\s+ (?<memo> \\S .*? ) )?\n..."
},
{
"rule": "NEW",
"severity": "medium",
"title": "The memo option is never used",
"where": "line 20",
"why": "(.*) always participates in the match, so $4 is '' rather than undef when the line has no memo, and $4 // $opt{memo} never reaches ...",
"fix": "(?: \\s+ (?<memo> \\S .*? ) )? # memo capture is undef when absent\nmemo => $field{memo} // $opt{memo} // '',"
},
{
"rule": "NEW",
"severity": "medium",
"title": "No encoding layer; a UTF-8 BOM drops the first transaction",
"where": "line 24",
"why": "The file is read as raw bytes, so a non-ASCII memo arrives undecoded, and an export that starts with a UTF-8 BOM makes line 1 fail the ...",
"fix": "open my $fh, '<:encoding(UTF-8)', $path or die \"Cannot open $path: $!\";\n...\n..."
},
{
"rule": "PL-RETUNDEF",
"severity": "medium",
"title": "return undef for a line that does not parse",
"where": "line 18",
"why": "In list context, e.g. map { Ledger::Entry->from_line($_) } @lines, this returns a one-element list (undef) and the caller gets an undef ...",
"fix": "return unless defined $line && $line =~ $LINE_RE;"
},
{
"rule": "NEW",
"severity": "medium",
"title": "Dropped dated lines are silent",
"where": "line 29",
"why": "When from_line declines a line, load_file pushes nothing and says nothing, so a transaction line with a bad amount disappears from the ...",
"fix": "elsif ($line =~ $DATED_RE) {\n warn \"skipped: $path line $.: not a ledger line\\n\";\n..."
},
{
"rule": "...",
"note": "five more findings, lower severity"
}
],
"prescan": [
{
"rule": "PL-ATARGS",
"call": "agree",
"note": "from_line is the only sub without a signature; sub from_line($class, $line, %opt) is a drop-in on 5.38."
},
{
"rule": "PL-RETUNDEF",
"call": "agree",
"note": "return undef yields (undef) in list context; a bare return is correct and load_file already tests truthiness."
},
{
"rule": "PL-POSCAP",
"call": "agree",
"note": "Named captures in a precompiled qr//x make the line format readable."
},
{
"rule": "PL-EVALIF",
"call": "agree",
"note": "Replaced with Try::Tiny, since native try is still experimental on 5.38."
}
],
"rewrite": {
"name": "Ledger/Entry.pm",
"code": "package Ledger::Entry;\nuse v5.36;\nuse Moo;\nuse Types::Standard qw(Str Num Enum);\nuse Try::Tiny;\nuse namespace::autoclean;\n\nhas account => (is => 'ro', isa => Str, required => 1);\nhas amount => (is => 'ro', isa => Num, required => 1);\nhas kind => (is => 'ro', isa => Enum[qw(debit credit)], default => 'debit');\nhas memo => (is => 'ro', isa => Str, default => '');\n\n..."
},
"kept": [
{
"rule": "NEW",
"what": "The sign convention: a negative amount in the file becomes a credit, and signed_amount returns credits as positive and debits as negative.",
"why": "This inverts the file's sign (\"-4\" reads back as +4), which may be deliberate accounting or a bug; the existing .t pins the debit-negative ..."
},
{
"rule": "NEW",
"what": "The parsed date is matched but not stored.",
"why": "Adding a date attribute extends the API; it is captured as (?<date>) so it is easy to add."
},
{
"rule": "NEW",
"what": "Floating-point money and use v5.36 with a final 1;",
"why": "Integer cents or Math::BigFloat would change amount's type; use v5.36 still runs on 5.38 and 1; is harmless."
},
{
"rule": "NEW",
"what": "Plain open rather than Path::Tiny.",
"why": "Path::Tiny is a new dependency; the three-argument open with an encoding layer already meets the rule."
}
],
"caveats": [
"Try::Tiny is a new dependency; if the project cannot take it, use eval { ...; 1 } or do { ... } instead.",
"I assumed bank exports are UTF-8; a Latin-1 or Windows-1252 export needs a different layer, such as '<:encoding(cp1252)'.",
"No sample export was shown, so the stricter amount pattern (no thousands separators, no currency symbols) may drop lines your banks really ...",
"Nothing was run: run perl -c lib/Ledger/Entry.pm and prove -l; the existing .t never calls from_line or load_file."
]
}
Ledger::Entry (Perl 5.38) - tests
Input - the same fields, with task set to tests:
{
"task": "tests",
"code": "package Ledger::Entry;\nuse v5.36;\nuse Moo;\nuse Types::Standard qw(Str Num Enum);\nuse namespace::autoclean;\n\nhas account => (is => 'ro', isa => Str, required => 1);\nhas amount => (is => 'ro', isa => Num, required => 1);\nhas kind => (is => 'ro', isa => Enum[qw(debit credit)], default => 'debit');\nhas memo => (is => 'ro', isa => Str, default => '');\n\nsub signed_amount($self) {\n return $self->kind eq 'credit' ? $self->amount : -$self->amount;\n}\n\nsub from_line {\n my ($class, $line, %opt) = @_;\n return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n my $kind = $3 < 0 ? 'credit' : 'debit';\n return $class->new(account => $2, amount => abs($3), kind => $kind, memo => $4 // $opt{memo} // '');\n}\n\nsub load_file($class, $path) {\n open my $fh, '<', $path or die \"Cannot open $path: $!\";\n my @entries;\n while (my $line = <$fh>) {\n my $entry = eval { $class->from_line($line) };\n if ($@) { warn \"skipped: $@\"; next }\n push @entries, $entry if $entry;\n }\n return @entries;\n}\n\nsub balance($class, @entries) {\n my $sum = 0;\n $sum += $_->signed_amount for @entries;\n return sprintf('%.2f', $sum);\n}\n\n1;",
"tests": "use v5.36;\nuse Test2::V0;\nuse Ledger::Entry;\n\nsubtest 'signed amounts' => sub {\n my $d = Ledger::Entry->new(account => 'cash', amount => 10);\n my $c = Ledger::Entry->new(account => 'cash', amount => 4, kind => 'credit');\n is($d->signed_amount, -10, 'debit is negative');\n is($c->signed_amount, 4, 'credit is positive');\n};\n\nsubtest 'balance' => sub {\n my @e = map { Ledger::Entry->new(account => 'cash', amount => $_) } 1, 2;\n is(Ledger::Entry->balance(@e), '-3.00', 'sums debits');\n};\n\ndone_testing;",
"version": "5.38",
"facts": "Perl version: 5.38.\nPrescan verdict: refactor (0 errors, 2 warnings, 2 notes).\nShape: module; package Ledger::Entry; public subs signed_amount, from_line, load_file, balance; private subs (none); exports (none); attributes account, amount, kind, memo.\nTests: the .t calls 2 of 4 public subs; never called: from_line, load_file.\nFindings (rule | severity | file | line | text):\n- PL-ATARGS | warn | code | line 17 | @_ unpacking instead of a signature | my ($class, $line, %opt) = @_;\n- PL-RETUNDEF | warn | code | line 18 | return undef | return undef unless $line =~ /^(\\d{4}-\\d{2}-\\d{2})\\s+(\\S+)\\s+(-?[\\d.]+)\\s*(.*)$/;\n- PL-POSCAP | info | code | line 19 | Positional captures (4 places in the file.) | my $kind = $3 < 0 ? 'credit' : 'debit';\n- PL-EVALIF | info | code | line 27 | eval { } then if ($@) | my $entry = eval { $class->from_line($line) };",
"focus": "Entries come from bank exports; a bad line should be skipped, not fatal."
}
Reply (abbreviated). The two existing subtests were kept, from_line and load_file (never called by the old .t) are now covered along with the four Moo attributes, and eight todo blocks encode intended behaviour the current code gets wrong, each listed in bugs:
{
"task": "tests",
"summary": "Keeps the two existing subtests and adds cases for all four public subs: the Moo attribute defaults and type checks, from_line on debits, credits, zero, CRLF and rejected lines, load_file on a temp export (headers, blank lines, an empty file, a missing file, ...",
"file": {
"name": "t/ledger-entry.t",
"code": "use v5.36;\nuse Test2::V0;\nuse Test::MockModule;\nuse File::Temp qw(tempdir);\nuse File::Spec;\n\nuse Ledger::Entry;\n\n# Writes raw bytes to a fresh temp file and returns its path.\nsub fixture($content) {\n my $path = File::Spec->catfile(tempdir(CLEANUP => 1), 'export.txt');\n open my $out, '>:raw', $path or die \"Cannot write $path: $!\";\n print {$out} $content;\n close $out or die \"Cannot close $path: $!\";\n return $path;\n}\n..."
},
"covers": [
{
"sub": "signed_amount",
"cases": [
"debit is negative",
"credit is positive",
"zero debit is zero"
]
},
{
"sub": "from_line",
"cases": [
"positive amount becomes a debit with memo",
"negative amount becomes an unsigned credit",
"missing memo is empty",
"..."
]
},
{
"sub": "load_file",
"cases": [
"loads entries in file order, skipping header, blank and junk lines",
"balance of the loaded file",
"empty file gives no entries",
"..."
]
},
{
"sub": "balance",
"cases": [
"sums debits",
"empty list is 0.00",
"credits offset debits",
"..."
]
},
{
"sub": "account",
"cases": [
"required"
]
},
{
"sub": "amount",
"cases": [
"rejects a non-numeric value"
]
},
{
"sub": "kind",
"cases": [
"defaults to debit",
"rejects a value outside debit and credit"
]
},
{
"sub": "memo",
"cases": [
"defaults to empty string"
]
}
],
"skipped": [],
"bugs": [
{
"sub": "from_line",
"what": "return undef on line 18 gives (undef) in list context, so a rejected line is a one-element list."
},
{
"sub": "from_line",
"what": "The memo option is never used: (.*) always matches, so $4 is '' not undef and $4 // $opt{memo} never falls through."
},
{
"sub": "from_line",
"what": "A CRLF line with a memo keeps the \\r (memo \"rent\\r\")."
},
{
"sub": "...",
"what": "five more"
}
],
"run": "prove -lv t/ledger-entry.t",
"caveats": [
"Assumes the module lives at lib/Ledger/Entry.pm so prove -l finds it, and that Moo, Types::Standard, namespace::autoclean and ...",
"No real bank export was shown; the fixtures are inline guesses at the 'YYYY-MM-DD account amount memo' format the regex expects.",
"The type-check messages are matched loosely (the value or attribute name), since their exact text comes from Types::Standard and Moo.",
"..."
]
}
Build the input yourself
The scanner and the reply checker are plain scripts that run under node (global.window = global; require("./perlscan.js"); require("./perlreply.js")), then:
const S = PerlScan, R = PerlReply;
const st = { code: fs.readFileSync("lib/Ledger/Entry.pm", "utf8"), tests: fs.readFileSync("t/ledger-entry.t", "utf8"),
version: "5.38", focus: "Entries come from bank exports; a bad line should be skipped, not fatal." };
const scan = S.scan(st); // version, verdict, counts, shape, coverage, findings
const b = S.buildInput(st, "review"); // or "tests"; clips long files and writes the facts
const wire = S.toWire(b.input); // every field a string - POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b) // {notes, warn}: anything the reply missed or got wrong
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://perl-clinic.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://perl-clinic.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// perl-clinic.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://perl-clinic.skillsafe.ai/tokens.html
// body is any JSON value; it is sent with JSON.stringify
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://perl-clinic.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// PerlClinic.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class PerlClinic {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://perl-clinic.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = +"YOUR_TOKEN" # from https://perl-clinic.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://perl-clinic.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://perl-clinic.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"perl-clinic"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "perl-clinic"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", '{"slug":"perl-clinic"}')).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "perl-clinic"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "perl-clinic"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "perl-clinic" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "perl-clinic"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "perl-clinic" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save one of the worked-example inputs above as input.json to follow along.
# input.json: the run input object - {"task": "review", "code": "package Ledger::Entry; ...", "tests": "use v5.36; ...", "version": "5.38", "facts": "Perl version: 5.38. ...", "focus": "..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "review", "code": "package Ledger::Entry; ...", "tests": "use v5.36; ...", "version": "5.38", "facts": "Perl version: 5.38. ...", "focus": "..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "review" or "tests", code, tests, version, facts, focus} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses perl-clinic:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a review and a test file for the same module are two runs - and its one automatic reformat retry (sent with a retry_note) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. A review carries verdict, summary, findings, prescan, rewrite, kept and caveats; a test file carries summary, file, covers, skipped, bugs, run and caveats. The code below prints the headline parts of either: for a review the verdict, the summary and each finding's severity and title; for tests the summary, the file name and each covered sub.
KEY="perl-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "tests"
then .summary, "file: \(.file.name)", (.covers[] | "covers: \(.sub)")
else "verdict: \(.verdict)", .summary, (.findings[] | "[\(.severity)] \(.title)") end' reply.json
import hashlib, time
key = "perl-clinic:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
if reply.get("task") == "tests":
print(reply["summary"])
print("file:", reply["file"]["name"])
for c in reply.get("covers", []):
print("covers:", c["sub"])
else:
print("verdict:", reply.get("verdict"))
print(reply.get("summary"))
for f in reply.get("findings", []):
print(f"[{f['severity']}] {f['title']}")
import { createHash } from "node:crypto";
const key = `perl-clinic:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated);
if (reply.task === "tests") {
console.log(reply.summary);
console.log("file:", reply.file.name);
for (const c of reply.covers || []) console.log("covers:", c.sub);
} else {
console.log("verdict:", reply.verdict);
console.log(reply.summary);
for (const f of reply.findings || []) console.log(`[${f.severity}] ${f.title}`);
}
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "perl-clinic:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
if reply["task"] == "tests" {
fmt.Println(reply["summary"])
file, _ := reply["file"].(map[string]any)
fmt.Println("file:", file["name"])
covers, _ := reply["covers"].([]any)
for _, x := range covers { fmt.Println("covers:", x.(map[string]any)["sub"]) }
} else {
fmt.Println("verdict:", reply["verdict"])
fmt.Println(reply["summary"])
findings, _ := reply["findings"].([]any)
for _, x := range findings {
f := x.(map[string]any)
fmt.Printf("[%v] %v\n", f["severity"], f["title"])
}
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "perl-clinic:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
if ("tests".equals(reply.path("task").asText())) {
System.out.println(reply.path("summary").asText());
System.out.println("file: " + reply.path("file").path("name").asText());
for (JsonNode c : reply.path("covers")) System.out.println("covers: " + c.path("sub").asText());
} else {
System.out.println("verdict: " + reply.path("verdict").asText());
System.out.println(reply.path("summary").asText());
for (JsonNode f : reply.path("findings")) System.out.println("[" + f.path("severity").asText() + "] " + f.path("title").asText());
}
require "digest"
key = "perl-clinic:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"]].inspect
if reply["task"] == "tests"
puts reply["summary"], "file: #{reply.dig("file", "name")}"
(reply["covers"] || []).each { |c| puts "covers: #{c["sub"]}" }
else
puts "verdict: #{reply["verdict"]}", reply["summary"]
(reply["findings"] || []).each { |f| puts "[#{f["severity"]}] #{f["title"]}" }
end
$key = "perl-clinic:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], "\n";
if (($reply["task"] ?? "") === "tests") {
echo $reply["summary"], "\n", "file: ", $reply["file"]["name"] ?? "", "\n";
foreach ($reply["covers"] ?? [] as $c) echo "covers: ", $c["sub"], "\n";
} else {
echo "verdict: ", $reply["verdict"] ?? "", "\n", $reply["summary"] ?? "", "\n";
foreach ($reply["findings"] ?? [] as $f) echo "[", $f["severity"], "] ", $f["title"], "\n";
}
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"perl-clinic:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
if ((string?)reply["task"] == "tests") {
Console.WriteLine(reply["summary"]);
Console.WriteLine($"file: {reply["file"]?["name"]}");
foreach (var c in reply["covers"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"covers: {c!["sub"]}");
} else {
Console.WriteLine($"verdict: {reply["verdict"]}");
Console.WriteLine(reply["summary"]);
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"[{f!["severity"]}] {f["title"]}");
}
Step 6 — POST /run-stream
The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.
# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: perl-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json (see the input table)
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "perl-clinic:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `perl-clinic:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "perl-clinic:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "perl-clinic:" + M.readTree(rev).path("task").asText() + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "perl-clinic:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$task = json_decode($rev, true)["task"];
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: perl-clinic:$task:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"perl-clinic:{JsonNode.Parse(rev)!["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);