Skip to content

Commit b02107e

Browse files
authored
feat: p.readOptional, p.env, s.int/s.nonEmptyString/s.url, typecheck success message (#61)
1 parent fe61d4f commit b02107e

5 files changed

Lines changed: 220 additions & 9 deletions

File tree

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,13 @@ When the context already lives in the workspace, prefer intent templates like th
8181
```ts
8282
s.string
8383
s.string("description")
84+
s.nonEmptyString // string with minLength: 1
85+
s.nonEmptyString("description")
86+
s.url // string with format: "uri"
87+
s.url("description")
8488
s.number
89+
s.integer
90+
s.int // alias for s.integer
8591
s.boolean
8692
s.unknown
8793
s.array(item, "description")
@@ -110,8 +116,12 @@ Prompt intents for shell and file operations are optimized for sandboxed agentic
110116
p.bash("git status --short")
111117
p.bash("npm test")
112118
p.read("README.md")
119+
p.readOptional("Dockerfile") // returns "" if file is absent
120+
p.readOptional(".eslintrc.json", "{}") // returns "{}" if file is absent
113121
p.write("README.md", "# Updated\n")
114122
p.glob("src/**/*.ts")
123+
p.env("GITHUB_TOKEN") // returns "" if variable is not set
124+
p.env("GITHUB_TOKEN", "unset") // returns "unset" if variable is not set
115125
p.json({ repo: "rig", stars: 42 })
116126

117127
const reviewWorkspace = agent({
@@ -286,7 +296,8 @@ Pass `--server` to start the Copilot server automatically as part of the run:
286296
cat ./program.ts | node skills/rig/rig.ts --server
287297
```
288298

289-
Pass `--typecheck` to typecheck the rig program and exit without executing it:
299+
Pass `--typecheck` to typecheck the rig program and exit without executing it.
300+
On success, prints `typecheck passed` to stdout and exits 0. On failure, throws with the TypeScript diagnostics.
290301

291302
```bash
292303
cat ./program.ts | node skills/rig/rig.ts --typecheck

skills/rig/SKILL.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,13 @@ Use explicit schemas in docs and generated samples.
124124
```ts
125125
s.string
126126
s.string("description")
127+
s.nonEmptyString // string with minLength: 1
128+
s.nonEmptyString("description")
129+
s.url // string with format: "uri"
130+
s.url("description")
127131
s.number
132+
s.integer
133+
s.int // alias for s.integer
128134
s.boolean
129135
s.unknown
130136
s.array(item)
@@ -149,8 +155,13 @@ Common examples:
149155
s.enum("bug", "feature", "question")
150156
s.optional(s.number)
151157
s.record(s.string)
158+
s.record(s.array(s.string)) // Record<string, string[]>
159+
s.record(s.object({ name: s.string, age: s.number }))
152160
s.nullable(s.string) // string | null
153161
s.literal("done") // exactly "done"
162+
s.nonEmptyString // non-empty string required
163+
s.url // valid URL string
164+
s.int // integer number (no floats)
154165
```
155166

156167
## Tools
@@ -188,8 +199,12 @@ Rig assumes the surrounding workflow already provides the sandbox and protection
188199
p.bash("git diff -- .")
189200
p.bash("npm test")
190201
p.read("README.md")
202+
p.readOptional("Dockerfile") // returns "" if file is absent
203+
p.readOptional(".eslintrc.json", "{}") // returns "{}" if file is absent
191204
p.write("README.md", "# Hello\n")
192205
p.glob("src/**/*.ts")
206+
p.env("GITHUB_TOKEN") // returns "" if variable is not set
207+
p.env("GITHUB_TOKEN", "unset") // returns "unset" if variable is not set
193208
p.json({ repo: "rig", stars: 42 })
194209
```
195210

@@ -217,11 +232,14 @@ const reviewAgent = agent({
217232
});
218233
```
219234

220-
- ``p`...` `` accepts `${p.bash(...)}`, `${p.read(...)}`, `${p.write(...)}`, `${p.glob(...)}`, and `${p.json(...)}` expressions.
235+
- ``p`...` `` accepts `${p.bash(...)}`, `${p.read(...)}`, `${p.readOptional(...)}`, `${p.write(...)}`, `${p.glob(...)}`, `${p.env(...)}`, and `${p.json(...)}` expressions.
236+
- Multiple `p.*` calls in the same template are resolved independently in order; each contributes its own instruction line.
221237
- Nested `PromptBuilder` values used as interpolations are inlined as plain text.
222238
- The rendered `PromptBuilder` replaces the instructions string when the agent prompt is assembled.
223239
- `p.write(path, contents)` contributes a write-file instruction to the prompt; it does **not** return the file path or contents as text. Use `p.read(path)` to read back the file in a subsequent expression.
224240
- `p.glob(pattern)` resolves to a list of matching paths at runtime; it is resolved by the Copilot runtime, not in-process.
241+
- `p.readOptional(path, fallback?)` reads a file if it exists; returns the fallback string (default `""`) if the file is absent. Use this instead of `p.read` when the file may not exist.
242+
- `p.env(name, fallback?)` reads an environment variable; returns the fallback string (default `""`) if the variable is not set.
225243
- `p.json(value)` returns a pretty-printed JSON string immediately; use it to inline structured data into a prompt template without calling `JSON.stringify` manually.
226244

227245
## Call-time options
@@ -349,7 +367,8 @@ Pass `--server` to have the harness start the Copilot server automatically befor
349367
echo "Review this diff" | node skills/rig/rig.ts src/program.ts --server
350368
```
351369

352-
Pass `--typecheck` to typecheck the rig program and exit without executing it:
370+
Pass `--typecheck` to typecheck the rig program and exit without executing it.
371+
On success, writes `typecheck passed` to stdout and exits 0. On failure, throws with the TypeScript diagnostic output.
353372

354373
```bash
355374
cat <<'RIG' | node skills/rig/rig.ts --typecheck

skills/rig/rig.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* T:LauncherIo type {stdin,stdout,stderr} override for launcher subprocess
2828
* T:JsonSchemaObject type {[key:string]:unknown} plain JSON Schema object
2929
* s.string/number/integer/boolean/null SchemaHelperFactory primitives; call as value or fn(desc)
30+
* s.int alias for s.integer; s.nonEmptyString string with minLength:1; s.url string with format:"uri"
3031
* s.array(items,desc?) ArraySchema; use for homogeneous lists, e.g. s.array(s.string)
3132
* s.object(props,desc?) ObjectSchema; s.optional(inner) marks field optional; s.nullable(inner) accepts inner|null; use for fixed-key shapes
3233
* s.record(valSchema,desc?) RecordSchema keyed by string; use for open-ended key→value maps
@@ -36,8 +37,10 @@
3637
* p`...` PromptBuilder template tag; interpolates PromptIntent|string|PromptBuilder
3738
* p.bash(cmd,opts?) PromptIntent bash execution declaration (not run in-process)
3839
* p.read(path,opts?) PromptIntent file read declaration
40+
* p.readOptional(path,fallback?,opts?) PromptIntent file read declaration; returns fallback (default "") if file absent
3941
* p.write(path,content,opts?) PromptIntent file write declaration
4042
* p.glob(pattern,opts?) PromptIntent glob file-list declaration (not run in-process)
43+
* p.env(name,fallback?,opts?) PromptIntent env var read declaration; returns fallback (default "") if not set
4144
* p.json(value) string JSON.stringify helper for inlining structured values in prompt templates
4245
* F:agent(spec) AgentFn<I,O>; spec={name,description,input,output,prompt,addons,maxTurns}
4346
* F:copilotEngine(opts?) AgentFactory wrapping CopilotClient+RuntimeConnection
@@ -67,7 +70,7 @@ import type { CopilotClientOptions } from "@github/copilot-sdk";
6770
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
6871
export type ValidationResult = { ok: true } | { ok: false; error: string };
6972

70-
export type StringSchema = { type: "string"; description?: string };
73+
export type StringSchema = { type: "string"; description?: string; minLength?: number; format?: string };
7174
export type NumberSchema = { type: "number"; description?: string };
7275
export type IntegerSchema = { type: "integer"; description?: string };
7376
export type BooleanSchema = { type: "boolean"; description?: string };
@@ -142,6 +145,15 @@ function createTypedPrimitiveSchema<T extends StringSchema | NumberSchema | Inte
142145
return factory;
143146
}
144147

148+
function createConstrainedStringSchema(constraint: Omit<StringSchema, "type" | "description">): SchemaHelperFactory<StringSchema> {
149+
const base = markAsSchema({ type: "string", ...constraint } as StringSchema);
150+
const factory = Object.assign(
151+
markAsSchema(((description?: string) => (description === undefined ? base : markAsSchema({ type: "string", ...constraint, description } as StringSchema))) as SchemaHelperFactory<StringSchema>),
152+
base,
153+
);
154+
return factory;
155+
}
156+
145157
function createUnknownSchema(): SchemaHelperFactory<UnknownSchema> {
146158
const base: UnknownSchema = markAsSchema({});
147159
const factory = Object.assign(
@@ -198,10 +210,16 @@ export type InferSchema<T> =
198210
export const s = {
199211
/** Schema for a `string` value. Call as `s.string` or `s.string("description")`. */
200212
string: createTypedPrimitiveSchema<StringSchema>("string"),
213+
/** Schema for a non-empty `string` value (minLength: 1). Call as `s.nonEmptyString` or `s.nonEmptyString("description")`. */
214+
nonEmptyString: createConstrainedStringSchema({ minLength: 1 }),
215+
/** Schema for a URL string (format: "uri"). Call as `s.url` or `s.url("description")`. */
216+
url: createConstrainedStringSchema({ format: "uri" }),
201217
/** Schema for a `number` value. Call as `s.number` or `s.number("description")`. */
202218
number: createTypedPrimitiveSchema<NumberSchema>("number"),
203219
/** Schema for an integer value. Serializes to `{"type":"integer"}` in JSON Schema. Call as `s.integer` or `s.integer("description")`. */
204220
integer: createTypedPrimitiveSchema<IntegerSchema>("integer"),
221+
/** Schema for an integer value. Alias for `s.integer`. Call as `s.int` or `s.int("description")`. */
222+
int: createTypedPrimitiveSchema<IntegerSchema>("integer"),
205223
/** Schema for a `boolean` value. Call as `s.boolean` or `s.boolean("description")`. */
206224
boolean: createTypedPrimitiveSchema<BooleanSchema>("boolean"),
207225
/** Schema for the JSON `null` literal. Call as `s.null` or `s.null("description")`. */
@@ -334,6 +352,13 @@ function serializeSchema(schema: Schema): JsonSchemaObject {
334352
return withDescription(obj);
335353
}
336354
if ("type" in schema) {
355+
if (schema.type === "string") {
356+
const { minLength, format } = schema as StringSchema;
357+
const base: JsonSchemaObject = { type: "string" };
358+
if (minLength !== undefined) base["minLength"] = minLength;
359+
if (format !== undefined) base["format"] = format;
360+
return withDescription(base);
361+
}
337362
return withDescription({ type: schema.type });
338363
}
339364
return withDescription({});
@@ -581,11 +606,12 @@ export type PromptIntentOptions = {
581606
export type PromptIntent = {
582607
__rig: "prompt";
583608
id: string;
584-
mode: "prompt.text" | "prompt.read" | "prompt.write" | "prompt.glob";
609+
mode: "prompt.text" | "prompt.read" | "prompt.write" | "prompt.glob" | "prompt.readOptional" | "prompt.env";
585610
command?: string;
586611
path?: string;
587612
contents?: string;
588613
pattern?: string;
614+
fallback?: string;
589615
options?: Omit<PromptIntentOptions, "signal">;
590616
};
591617

@@ -613,6 +639,28 @@ type PromptHelpers = {
613639
* input: { source: p.read("src/index.ts") }
614640
*/
615641
read(path: string, options?: PromptIntentOptions): PromptIntent;
642+
/**
643+
* Declarative intent that instructs the LLM to read the file at `path` if it
644+
* exists and substitute its contents into the prompt. If the file does not
645+
* exist, the `fallback` string (default `""`) is used instead. The file is
646+
* **not** read in-process; resolution happens inside the Copilot runtime.
647+
*
648+
* @example
649+
* input: { config: p.readOptional(".eslintrc.json") }
650+
* input: { config: p.readOptional(".eslintrc.json", "{}") }
651+
*/
652+
readOptional(path: string, fallback?: string, options?: PromptIntentOptions): PromptIntent;
653+
/**
654+
* Declarative intent that instructs the LLM to read the environment variable
655+
* `name` and substitute its value into the prompt. If the variable is not
656+
* set, the `fallback` string (default `""`) is used instead. The variable is
657+
* **not** read in-process; resolution happens inside the Copilot runtime.
658+
*
659+
* @example
660+
* input: { token: p.env("GITHUB_TOKEN") }
661+
* input: { token: p.env("GITHUB_TOKEN", "unset") }
662+
*/
663+
env(name: string, fallback?: string, options?: PromptIntentOptions): PromptIntent;
616664
/**
617665
* Declarative intent that instructs the LLM to write `contents` to `path`.
618666
* The write is **not** performed in-process; it is resolved by the Copilot
@@ -754,6 +802,12 @@ export const p: PromptHelpers = Object.assign(
754802
read(path: string, options?: PromptIntentOptions): PromptIntent {
755803
return createPromptIntent("prompt.read", withOptions({ path }, options));
756804
},
805+
readOptional(path: string, fallback = "", options?: PromptIntentOptions): PromptIntent {
806+
return createPromptIntent("prompt.readOptional", withOptions({ path, fallback }, options));
807+
},
808+
env(name: string, fallback = "", options?: PromptIntentOptions): PromptIntent {
809+
return createPromptIntent("prompt.env", withOptions({ command: name, fallback }, options));
810+
},
757811
write(path: string, contents: string, options?: PromptIntentOptions): PromptIntent {
758812
return createPromptIntent("prompt.write", withOptions({ path, contents }, options));
759813
},
@@ -1085,6 +1139,7 @@ async function runRootAgentFromStdin(
10851139
const resolvedPath = isAbsolute(programPath) ? programPath : resolve(cwd, programPath);
10861140
if (options.typecheck) {
10871141
await typecheckProgram(resolvedPath, cwd);
1142+
io.stdout.write("typecheck passed\n");
10881143
return;
10891144
}
10901145

@@ -1124,6 +1179,7 @@ async function runProgramCodeFromStdin(
11241179
try {
11251180
if (options.typecheck) {
11261181
await typecheckProgram(tempProgramPath, cwd, "<stdin>");
1182+
io.stdout.write("typecheck passed\n");
11271183
return;
11281184
}
11291185
configureAgent(copilotEngine(resolveCopilotOptions(cwd, options)));
@@ -1602,7 +1658,17 @@ function validateSchema(value: unknown, schema: Schema, path: string, optional:
16021658
return ok();
16031659
}
16041660
if ("type" in schema) {
1605-
if (schema.type === "string") return typeof value === "string" ? ok() : bad(path, "string", value);
1661+
if (schema.type === "string") {
1662+
if (typeof value !== "string") return bad(path, "string", value);
1663+
const { minLength, format } = schema as StringSchema;
1664+
if (minLength !== undefined && value.length < minLength) {
1665+
return { ok: false, error: `${path}: expected string with minLength ${minLength}, got empty string` };
1666+
}
1667+
if (format === "uri") {
1668+
try { new URL(value); } catch { return { ok: false, error: `${path}: expected a valid URL, got ${JSON.stringify(value)}` }; }
1669+
}
1670+
return ok();
1671+
}
16061672
if (schema.type === "number") return typeof value === "number" ? ok() : bad(path, "number", value);
16071673
if (schema.type === "integer") return (typeof value === "number" && Number.isInteger(value)) ? ok() : bad(path, "integer", value);
16081674
if (schema.type === "boolean") return typeof value === "boolean" ? ok() : bad(path, "boolean", value);
@@ -1684,6 +1750,10 @@ function renderPromptIntentInstruction(intent: PromptIntent): string {
16841750
return `Run bash command and return stdout as text: ${intent.command}${promptExecutionContext()}${options}`;
16851751
case "prompt.read":
16861752
return `Read file and return its contents as text: ${JSON.stringify(requiredPath(intent))}${promptExecutionContext()}${options}`;
1753+
case "prompt.readOptional":
1754+
return `Read file and return its contents as text: ${JSON.stringify(requiredPath(intent))}. If the file does not exist, return ${JSON.stringify(intent.fallback ?? "")} instead${promptExecutionContext()}${options}`;
1755+
case "prompt.env":
1756+
return `Read environment variable ${JSON.stringify(intent.command ?? "")} and return its value as text. If the variable is not set, return ${JSON.stringify(intent.fallback ?? "")} instead${promptExecutionContext()}${options}`;
16871757
case "prompt.write":
16881758
return `Write file at path ${JSON.stringify(requiredPath(intent))} with contents:\n${intent.contents ?? ""}${promptExecutionContext()}${options}`;
16891759
case "prompt.glob":

src/launcher.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ it("typechecks a program file without executing it", async () => {
253253

254254
await runLauncherCli([fixturePath, "--typecheck"], {}, { stdin, stdout });
255255

256-
expect(output.join("")).toBe("");
256+
expect(output.join("")).toBe("typecheck passed\n");
257257
expect(mocks.createSession).not.toHaveBeenCalled();
258258
});
259259

@@ -271,7 +271,7 @@ it("falls back to the skill tsconfig when cwd tsconfig is missing", async () =>
271271

272272
await runLauncherCli([fixturePath, "--typecheck"], { cwd: skillDirCwd }, { stdin, stdout });
273273

274-
expect(output.join("")).toBe("");
274+
expect(output.join("")).toBe("typecheck passed\n");
275275
expect(mocks.createSession).not.toHaveBeenCalled();
276276
});
277277

@@ -312,7 +312,7 @@ export default root;
312312

313313
await runLauncherCli(["--typecheck"], {}, { stdin, stdout });
314314

315-
expect(output.join("")).toBe("");
315+
expect(output.join("")).toBe("typecheck passed\n");
316316
expect(mocks.createSession).not.toHaveBeenCalled();
317317
});
318318

0 commit comments

Comments
 (0)