spl-lang 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # spl-lang: Semantic Prompt Language interpreter
2
+
3
+ A Python-like language where LLM calls are part of the syntax. Everything outside LLM calls is deterministic; LLM
4
+ calls go through the GitHub Copilot SDK. See [../LANG-PRD.md](../LANG-PRD.md) for the full spec,
5
+ [docs/syntax.ebnf](docs/syntax.ebnf) for the grammar and the [documentation site](../docs/index.html) (open it in a
6
+ browser, or publish `docs/` with GitHub Pages) for guides and reference.
7
+
8
+ ## Run
9
+
10
+ ```
11
+ npm install && npm run build
12
+ node dist/cli.js login # once: authenticate with GitHub Copilot
13
+ node dist/cli.js examples/live.spl
14
+ npx file:./lang examples/live.spl # npx from a checkout (or `npx spl-lang` once published)
15
+ ```
16
+
17
+ Requires Node 20+ and an authenticated GitHub Copilot.
18
+
19
+ ```
20
+ spl <script.spl> [--log-level error|info|debug] [--silent] [--model <id>]
21
+ [--store <file.json> | --store-module <module>]
22
+ [--timeout <s>] [--cwd <dir>] [--check]
23
+ spl login # GitHub device flow: prints a code, you confirm it in the browser
24
+ spl logout # removes the credentials saved by `spl login`
25
+ ```
26
+
27
+ * stdout: the value of `success` or a top-level `return` (strings raw, everything else as JSON) or the failure payload.
28
+ * stderr: logs, including model and reasoning effort of every LLM call.
29
+ * Exit code: `0` success, `1` uncaught failure, `2` syntax/usage error.
30
+
31
+ `spl login` does nothing if the runtime is already authenticated (an earlier login, a Copilot CLI / `gh` login or
32
+ `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`). Otherwise it runs the device flow and saves the token to
33
+ `~/.spl/credentials.json` (override the folder with `SPL_HOME`); scripts use that token automatically. A script that
34
+ needs an LLM and finds no credentials fails with `llm_error` and tells you to run `spl login`.
35
+
36
+ ## Language at a glance
37
+
38
+ ```
39
+ @{model='gpt-5.5'}
40
+
41
+ def fib(n) {
42
+ if n < 2 { return n }
43
+ return fib(n - 1) + fib(n - 2)
44
+ }
45
+
46
+ let joke = ?{len='sentence', fmt='object'} 'A joke about programmers' # JSON validated and parsed
47
+ let why = think{level='high', show=True} 'Why is {joke.setup} funny?'
48
+
49
+ let color = None
50
+ enforce 'Answer in English' {
51
+ forbid 'profanity' { let color = ?{len='word'} 'Your favorite color?' } # results leave the block
52
+ }
53
+
54
+ { # a bare block is a nested scope
55
+ let color = 'Red' # shadows the outer variable
56
+ log(color)
57
+ }
58
+
59
+ restrict './out' { # also: restrict net 'host,..' / restrict exec 'git,npm'
60
+ let f = write_file{path='./out/a.txt', content='{fib(10)}'} 'Confirm'
61
+ }
62
+
63
+ try { assert fib(5) == 4 'wrong' } except err { log(err.reason) }
64
+ success {'joke': joke, 'why': why, 'color': color}
65
+ ```
66
+
67
+ Decisions worth knowing (the PRD leaves them open):
68
+
69
+ * **Blocks use braces**, statements end at a newline.
70
+ * **Every modification of a variable starts with `let`**: `let x = 1`, `let x += 1`, `let d['k'] = 2`, `let d.k = 3`.
71
+ A bare `x = 1` is a syntax error.
72
+ * **Scoping.** `let NAME = ...` binds in the current scope. Bare `{ ... }` blocks and function bodies open a new scope,
73
+ so `let` there shadows an outer variable and the outer one is untouched. `if`/`elif`/`else`, `for`, `while`,
74
+ `try`/`except`, `enforce`, `forbid` and `restrict` bodies share the enclosing scope, so `let` inside them updates the
75
+ enclosing variable: this is how the answer of an LLM call leaves an `enforce`/`forbid`/`restrict` block. A function
76
+ cannot rebind its caller's variables (use a list or dict to share state); names are read through all enclosing scopes.
77
+ * **`return` exits immediately** from any scope: the innermost function, or the whole script when used at the top level
78
+ (like `success`). `break`/`continue` still belong to loops.
79
+ * **`?` is a prefix operator**: `?'a' + b` is `(?'a') + b`, `-?'n'` is `-(?'n')`.
80
+ * **LLM commands have defaults and braces are optional**: `?'q'`, `think 'q'` (level `medium`), and tools without
81
+ parameters, e.g. `read_file 'Read notes.txt and summarize it'`.
82
+ * **Every string interpolates `{expr}`**. Write `{{`/`}}` or `\{`/`\}` for literal braces, or use a raw string
83
+ `r'{"a": 1}'`. Values are substituted as is: strings raw, `True`/`False`/`None`, lists and dicts as JSON.
84
+ * Tool calls (`read_file`, `write_file`, `web_fetch`, `web_search`, `shell`): when the script supplies every parameter
85
+ the tool needs, the runtime runs it first (restriction-checked), and the output plus your instructions go to the LLM
86
+ whose answer is the value. When a parameter is missing (`read_file 'summarize notes.txt'`,
87
+ `write_file{path='a.txt'} 'write a haiku'`, `web_search 'latest Node.js LTS'`) the LLM calls the tool itself with the
88
+ parameters you gave kept fixed. `web_search` queries DuckDuckGo and counts as network access for `restrict net`.
89
+ * Builtins: `len str int float bool type list range abs round min max sum sorted reversed enumerate zip any all keys
90
+ values items json_parse json_stringify log`, plus Python-style string/list/dict methods. `log(...)` writes to stderr.
91
+ * Unknown modifiers/values, `break` outside loops, `try` without `except`, a modification without `let`, etc. are
92
+ syntax errors (exit code 2).
93
+
94
+ ## Restrictions
95
+
96
+ Two layers: the LLM is told about active restrictions in the prompt, and the runtime enforces them. The LLM only gets
97
+ the five runtime tools (`read_file`, `write_file`, `web_fetch`, `web_search`, `shell`); every call is checked against
98
+ the active `restrict` scopes and any other Copilot permission request is rejected. A violation raises a
99
+ `restriction_violation` failure (catchable with `try/except`).
100
+
101
+ * Paths are canonicalized (symlinks resolved, `..` removed) relative to `--cwd` before the check.
102
+ * `net` matches exact hosts (and `*.example.com` wildcards); redirects are re-checked on every hop. `web_search`
103
+ needs `html.duckduckgo.com` to be allowed.
104
+ * `exec` matches the executable name. While any restriction is active, `shell` accepts only simple commands (no
105
+ `&|;<>` redirects, substitution or chaining) so the check is exact; path-like and URL arguments are also checked
106
+ against `fs`/`net`. This is best effort for programs that touch files or the network on their own.
107
+ * Nested restrictions of one kind intersect and can never widen.
108
+
109
+ ## Reproducibility
110
+
111
+ Control flow, arithmetic and interpolation are deterministic. The Copilot SDK has no `temperature` or `seed` setting,
112
+ so the language has none either. `think` levels map to the SDK reasoning effort; if the selected model does not support
113
+ it a warning is logged and the model default is used. Guard against variance with `assert` and `fmt='object'`.
114
+
115
+ ## Variable store
116
+
117
+ `--store vars.json` loads top-level variables from the file at start and writes them back at exit (also on failure).
118
+ `--store-module ./mystore.mjs` plugs in your own store: export `createStore()` returning `{ load(): Record, save(vars) }`.
119
+
120
+ ## Examples
121
+
122
+ All in [examples/](examples); run from the project root, `--cwd <dir>` points a script at another project. The example
123
+ syntax is checked by `tests/examples.test.ts`; `triage-issues` is also run on Copilot there.
124
+
125
+ | Script | Shows |
126
+ |--------|-------|
127
+ | `commit-message.spl` | `shell` under `restrict exec 'git'`, validation helper, retry loop |
128
+ | `triage-issues.spl` | `fmt='object'` + `assert` validation with feedback retries, aggregation, summary |
129
+ | `extract-invoice.spl` | structured extraction cross-checked with arithmetic before it is accepted |
130
+ | `code-review.spl` | `think` then structure, `restrict` on read and on write, `fail` as a CI gate |
131
+ | `release-notes.spl` | nested `forbid`/`enforce`, git history to a file inside `./out` |
132
+ | `web-digest.spl` | `restrict net`, `web_fetch`, catching a blocked second host |
133
+ | `fix-failing-tests.spl` | agent loop: run `npm test`, let the LLM fix the source, repeat |
134
+ | `translate-docs.spl` | `write_file` without `content` (LLM writes, path pinned), dict iteration |
135
+ | `journal.spl` | state across runs with `--store` |
136
+ | `basics.spl`, `live.spl`, `restrict-live.spl`, `tools-live.spl` | small feature tours |
137
+
138
+ ## Develop
139
+
140
+ ```
141
+ npm test # builds, then runs parser, interpreter, restriction and CLI tests, plus live tests on Copilot
142
+ ```
143
+
144
+ The live tests (`tests/live.test.ts`, one example in `tests/examples.test.ts`) call the real GitHub Copilot service and
145
+ are skipped automatically when the runtime is not authenticated: run `spl login` first to include them.
146
+
147
+ The VS Code extension (syntax highlighting, snippets) lives in `../vscode`.
package/dist/ast.d.ts ADDED
@@ -0,0 +1,177 @@
1
+ export type StrPart = string | Expr;
2
+ export type Expr = {
3
+ t: 'num';
4
+ v: number;
5
+ } | {
6
+ t: 'str';
7
+ parts: StrPart[];
8
+ } | {
9
+ t: 'bool';
10
+ v: boolean;
11
+ } | {
12
+ t: 'none';
13
+ } | {
14
+ t: 'name';
15
+ name: string;
16
+ line: number;
17
+ } | {
18
+ t: 'list';
19
+ items: Expr[];
20
+ } | {
21
+ t: 'dict';
22
+ entries: [Expr, Expr][];
23
+ } | {
24
+ t: 'unary';
25
+ op: 'not' | '-' | '+';
26
+ e: Expr;
27
+ } | {
28
+ t: 'binary';
29
+ op: string;
30
+ l: Expr;
31
+ r: Expr;
32
+ } | {
33
+ t: 'logic';
34
+ op: 'and' | 'or';
35
+ l: Expr;
36
+ r: Expr;
37
+ } | {
38
+ t: 'compare';
39
+ ops: string[];
40
+ operands: Expr[];
41
+ } | {
42
+ t: 'ternary';
43
+ cond: Expr;
44
+ then: Expr;
45
+ else: Expr;
46
+ } | {
47
+ t: 'call';
48
+ callee: Expr;
49
+ args: Expr[];
50
+ } | {
51
+ t: 'member';
52
+ obj: Expr;
53
+ name: string;
54
+ } | {
55
+ t: 'index';
56
+ obj: Expr;
57
+ index: Expr;
58
+ } | {
59
+ t: 'slice';
60
+ obj: Expr;
61
+ lo?: Expr;
62
+ hi?: Expr;
63
+ } | {
64
+ t: 'ask';
65
+ mods: AskMods;
66
+ prompt: Expr;
67
+ } | {
68
+ t: 'think';
69
+ mods: ThinkMods;
70
+ prompt: Expr;
71
+ } | {
72
+ t: 'tool';
73
+ tool: ToolName;
74
+ params: Record<string, Expr>;
75
+ instructions: Expr;
76
+ };
77
+ export interface AskMods {
78
+ len?: 'word' | 'sentence' | 'paragraph' | 'long';
79
+ fmt?: 'text' | 'object';
80
+ }
81
+ export interface ThinkMods {
82
+ level?: 'low' | 'medium' | 'high';
83
+ show?: boolean;
84
+ }
85
+ export type ToolName = 'read_file' | 'write_file' | 'web_fetch' | 'web_search' | 'shell';
86
+ export type RestrictKind = 'fs' | 'net' | 'exec';
87
+ export type AssignTarget = {
88
+ t: 'name';
89
+ name: string;
90
+ } | {
91
+ t: 'member';
92
+ obj: Expr;
93
+ name: string;
94
+ } | {
95
+ t: 'index';
96
+ obj: Expr;
97
+ index: Expr;
98
+ };
99
+ interface Base {
100
+ line: number;
101
+ }
102
+ export type Stmt = Base & ({
103
+ t: 'let';
104
+ target: AssignTarget;
105
+ op: '=' | '+=' | '-=' | '*=' | '/=' | '//=' | '%=';
106
+ value: Expr;
107
+ } | {
108
+ t: 'block';
109
+ body: Stmt[];
110
+ } | {
111
+ t: 'expr';
112
+ e: Expr;
113
+ } | {
114
+ t: 'if';
115
+ branches: {
116
+ cond: Expr;
117
+ body: Stmt[];
118
+ }[];
119
+ orelse?: Stmt[];
120
+ } | {
121
+ t: 'for';
122
+ vars: string[];
123
+ iter: Expr;
124
+ body: Stmt[];
125
+ } | {
126
+ t: 'while';
127
+ cond: Expr;
128
+ body: Stmt[];
129
+ } | {
130
+ t: 'break';
131
+ } | {
132
+ t: 'continue';
133
+ } | {
134
+ t: 'pass';
135
+ } | {
136
+ t: 'def';
137
+ name: string;
138
+ params: string[];
139
+ body: Stmt[];
140
+ } | {
141
+ t: 'return';
142
+ value?: Expr;
143
+ } | {
144
+ t: 'try';
145
+ body: Stmt[];
146
+ errName: string;
147
+ handler: Stmt[];
148
+ } | {
149
+ t: 'assert';
150
+ cond: Expr;
151
+ message?: Expr;
152
+ } | {
153
+ t: 'fail';
154
+ reason: Expr;
155
+ } | {
156
+ t: 'success';
157
+ value?: Expr;
158
+ } | {
159
+ t: 'constraint';
160
+ kind: 'enforce' | 'forbid';
161
+ text: Expr;
162
+ body: Stmt[];
163
+ } | {
164
+ t: 'restrict';
165
+ kind: RestrictKind;
166
+ arg: Expr;
167
+ body: Stmt[];
168
+ });
169
+ export interface Directives {
170
+ model?: string;
171
+ }
172
+ export interface Program {
173
+ directives: Directives;
174
+ body: Stmt[];
175
+ lines: string[];
176
+ }
177
+ export {};
package/dist/ast.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/auth.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export declare class AuthError extends Error {
2
+ }
3
+ export declare function credentialsPath(): string;
4
+ /** The token saved by `spl login`, if any. Environment tokens (COPILOT_GITHUB_TOKEN, GH_TOKEN, ...) are handled by the SDK. */
5
+ export declare function loadToken(): string | undefined;
6
+ /** Removes the saved token; returns whether there was one. */
7
+ export declare function logout(): boolean;
8
+ export interface AuthStatus {
9
+ isAuthenticated: boolean;
10
+ login?: string;
11
+ statusMessage?: string;
12
+ }
13
+ /** Asks the Copilot runtime who it is authenticated as, optionally with an explicit token. */
14
+ export declare function authStatus(token?: string): Promise<AuthStatus>;
15
+ /**
16
+ * Authenticates against GitHub Copilot. Does nothing when the runtime is already authenticated
17
+ * (saved token, environment token or a Copilot CLI / gh login); otherwise runs the device flow and saves the token.
18
+ */
19
+ export declare function login(say: (line: string) => void): Promise<AuthStatus>;
package/dist/auth.js ADDED
@@ -0,0 +1,110 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { CopilotClient } from '@github/copilot-sdk';
5
+ /** OAuth app of the GitHub Copilot CLI; its device flow yields tokens the Copilot runtime accepts. */
6
+ const CLIENT_ID = 'Ov23ctDVkRmgkPke0Mmm';
7
+ const SCOPE = 'read:user';
8
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code';
9
+ const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
10
+ export class AuthError extends Error {
11
+ }
12
+ export function credentialsPath() {
13
+ return path.join(process.env.SPL_HOME ?? path.join(os.homedir(), '.spl'), 'credentials.json');
14
+ }
15
+ /** The token saved by `spl login`, if any. Environment tokens (COPILOT_GITHUB_TOKEN, GH_TOKEN, ...) are handled by the SDK. */
16
+ export function loadToken() {
17
+ try {
18
+ const data = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
19
+ return typeof data.token === 'string' && data.token ? data.token : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ function saveToken(token) {
26
+ const file = credentialsPath();
27
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
28
+ fs.writeFileSync(file, JSON.stringify({ token }), { mode: 0o600 });
29
+ }
30
+ /** Removes the saved token; returns whether there was one. */
31
+ export function logout() {
32
+ try {
33
+ fs.rmSync(credentialsPath());
34
+ return true;
35
+ }
36
+ catch (e) {
37
+ if (e.code === 'ENOENT')
38
+ return false;
39
+ throw new AuthError(`cannot remove '${credentialsPath()}': ${e.message}`);
40
+ }
41
+ }
42
+ /** Asks the Copilot runtime who it is authenticated as, optionally with an explicit token. */
43
+ export async function authStatus(token) {
44
+ const client = new CopilotClient(token ? { gitHubToken: token } : {});
45
+ try {
46
+ await client.start();
47
+ return await client.getAuthStatus();
48
+ }
49
+ catch (e) {
50
+ throw new AuthError(`cannot reach the Copilot runtime: ${e.message}`);
51
+ }
52
+ finally {
53
+ await client.stop().catch(() => undefined);
54
+ }
55
+ }
56
+ async function postForm(url, body) {
57
+ const res = await fetch(url, {
58
+ method: 'POST',
59
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
60
+ body: new URLSearchParams(body),
61
+ signal: AbortSignal.timeout(30_000),
62
+ });
63
+ if (!res.ok)
64
+ throw new AuthError(`GitHub answered HTTP ${res.status} for ${url}`);
65
+ return (await res.json());
66
+ }
67
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
68
+ /** GitHub device flow: the user enters a code in the browser, the token is polled for. */
69
+ async function deviceFlow(say) {
70
+ const code = await postForm(DEVICE_CODE_URL, { client_id: CLIENT_ID, scope: SCOPE });
71
+ say(`Open ${code.verification_uri} and enter the code: ${code.user_code}`);
72
+ say('Waiting for authorization...');
73
+ let interval = (code.interval ?? 5) * 1000;
74
+ const deadline = Date.now() + code.expires_in * 1000;
75
+ while (Date.now() < deadline) {
76
+ await sleep(interval);
77
+ const res = await postForm(ACCESS_TOKEN_URL, {
78
+ client_id: CLIENT_ID,
79
+ device_code: code.device_code,
80
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
81
+ });
82
+ if (res.access_token)
83
+ return res.access_token;
84
+ switch (res.error) {
85
+ case 'authorization_pending': break;
86
+ case 'slow_down':
87
+ interval += 5000;
88
+ break;
89
+ case 'access_denied': throw new AuthError('authorization was denied');
90
+ case 'expired_token': throw new AuthError('the device code expired; run login again');
91
+ default: throw new AuthError(res.error_description ?? res.error ?? 'unexpected response from GitHub');
92
+ }
93
+ }
94
+ throw new AuthError('the device code expired; run login again');
95
+ }
96
+ /**
97
+ * Authenticates against GitHub Copilot. Does nothing when the runtime is already authenticated
98
+ * (saved token, environment token or a Copilot CLI / gh login); otherwise runs the device flow and saves the token.
99
+ */
100
+ export async function login(say) {
101
+ const current = await authStatus(loadToken());
102
+ if (current.isAuthenticated)
103
+ return current;
104
+ const token = await deviceFlow(say);
105
+ const status = await authStatus(token);
106
+ if (!status.isAuthenticated)
107
+ throw new AuthError(status.statusMessage ?? 'the token was not accepted by the Copilot runtime');
108
+ saveToken(token);
109
+ return status;
110
+ }
@@ -0,0 +1,8 @@
1
+ import { type BuiltinFunction, type Value } from './values.js';
2
+ export interface BuiltinContext {
3
+ log: (message: string) => void;
4
+ }
5
+ /** Values that can be iterated by `for`, `list()`, `sorted()` etc. */
6
+ export declare function iterate(v: Value): Value[];
7
+ export declare function makeBuiltins(ctx: BuiltinContext): Map<string, BuiltinFunction>;
8
+ export declare function callMethod(self: Value, name: string, args: Value[]): Value;