@sous-io/sous 0.2.1 → 0.2.3
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/docs/markdown/README.md +4 -0
- package/docs/markdown/_sidebar.md +5 -1
- package/docs/markdown/commands.md +293 -274
- package/docs/markdown/configuration.md +7 -0
- package/docs/markdown/repositories-authoring.md +210 -302
- package/docs/markdown/repositories-consuming.md +221 -468
- package/docs/markdown/repositories-file-formats.md +216 -980
- package/docs/markdown/repositories-providers.md +322 -0
- package/docs/markdown/repositories-quickstart.md +340 -0
- package/docs/markdown/repositories-troubleshooting.md +336 -0
- package/docs/markdown/repositories-variables.md +250 -298
- package/docs/markdown/repositories.md +262 -228
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/compile.ts +4 -2
- package/src/lib/build-service.ts +52 -4
- package/src/lib/markdown-compiler.ts +20 -1
- package/src/lib/repos/recipe-targets.ts +9 -1
- package/src/lib/settings.ts +51 -3
- package/src/lib/vars/answers.ts +184 -0
- package/src/lib/vars/definition-source.ts +9 -0
- package/src/lib/vars/index.ts +1 -0
|
@@ -1,1084 +1,320 @@
|
|
|
1
1
|
# Repository File Formats
|
|
2
2
|
|
|
3
|
-
Repositories
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The reference for every file and config key in the Repositories system; the pages explaining how to use them
|
|
4
|
+
are listed at the end. You write the repository manifest and one recipe manifest per recipe; sous writes the
|
|
5
|
+
rest: the published index, a project's lockfile, the marker on a store entry, and the links map.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
| `sous.repo.yaml` | repository root | you | Declares the repository: its namespaces and where its recipes live |
|
|
10
|
-
| `sous.recipe.yaml` | each recipe folder | you | Declares one recipe: version, dependencies, contents, variables |
|
|
11
|
-
| `sous.index.json` | repository root | `sous repo release` | Every namespace, recipe and published version, with content hashes |
|
|
12
|
-
| `sous.lock.json` | a project's `.sous/` | sous | What the project actually resolved to |
|
|
13
|
-
| `.sous.entry.json` | each store entry | sous | Makes a cached recipe version self-describing |
|
|
14
|
-
| `sous.links.json` | `.sous/` or `$SOUS_HOME` | `sous repo link` | Redirects a repository at a local working copy |
|
|
7
|
+
Every one carries `formatVersion: 1`; a future incompatible change raises that number, so an older sous
|
|
8
|
+
refuses a file it would otherwise misread.
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
?> Both hand-written manifests are YAML or JSON, never JavaScript: `.yaml`, `.yml`, `.json` or `.jsonc`
|
|
11
|
+
(both JSON forms allow comments and trailing commas). Two in one directory is an error, not a first-match
|
|
12
|
+
win. Unknown keys are rejected except extension keys starting with `x-`, carried through untouched; [the one
|
|
13
|
+
sous reads](repositories-authoring.md#declare-the-variables-a-recipe-needs) is `x-intentional`. Every path
|
|
14
|
+
in a manifest is checked: absolute paths, backslashes, `.` or `..` segments, empty segments and trailing
|
|
15
|
+
slashes are refused.
|
|
22
16
|
|
|
23
17
|
## Refs: how anything is named
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
project's subscriptions. A recipe manifest names its dependencies by LOCATION instead, which is
|
|
27
|
-
a small variation on the same grammar; see [dependencies](#dependencies-named-by-location).
|
|
28
|
-
|
|
29
|
-
```
|
|
19
|
+
```text
|
|
30
20
|
ref := [ repo ":" ] namespace [ "/" recipe [ "@" range ] ]
|
|
31
21
|
```
|
|
32
22
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
| `workflow/task-files` | One recipe, any published version |
|
|
37
|
-
| `workflow/task-files@^1.2.0` | One recipe, constrained to a semantic version range |
|
|
38
|
-
| `sous-recipes:workflow/task-files@^1.2.0` | The same recipe, in a named repository |
|
|
23
|
+
So `workflow` is a whole namespace, `workflow/task-files@^1.2.0` one recipe constrained to a range (npm's
|
|
24
|
+
rules; namespaces are not versioned), and `sous-recipes:workflow/task-files` that recipe in a named
|
|
25
|
+
repository. Only a consuming project writes the `repo:` qualifier; it is that project's own label.
|
|
39
26
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
nothing in a published manifest and is refused there.
|
|
49
|
-
- **A version range applies to a recipe.** Namespaces are not versioned, so `workflow@^1.0.0` is
|
|
50
|
-
an error.
|
|
51
|
-
- **Ranges follow npm's rules.** `^1.2.0`, `~2.1`, `>=1.0.0 <2.0.0`, `1.x` and `*` all behave
|
|
52
|
-
exactly as they do in npm, because sous resolves them with npm's own `semver` package.
|
|
53
|
-
Prerelease versions sit out of range matching unless a subscription opts in.
|
|
54
|
-
|
|
55
|
-
Namespace names, recipe names and repository short names are lowercase kebab-case: a letter,
|
|
56
|
-
then letters, digits or hyphens.
|
|
27
|
+
| Thing | Shape |
|
|
28
|
+
|-------|-------|
|
|
29
|
+
| Repository short name, namespace, recipe name | lowercase kebab-case |
|
|
30
|
+
| Recipe key | always both segments, `namespace/recipe` |
|
|
31
|
+
| Repository identity | the host, then the path it lives at, lowercased |
|
|
32
|
+
| Variable name, environment variable name | camelCase, and upper snake case |
|
|
33
|
+
| Content hash | `sha256-` and 64 lowercase hex characters |
|
|
34
|
+
| Timestamp | ISO 8601 |
|
|
57
35
|
|
|
58
36
|
## `sous.repo.yaml`: the repository manifest
|
|
59
37
|
|
|
60
|
-
The entry point sous reads to learn what a repository publishes. It lives at the repository root
|
|
61
|
-
and may be written as `sous.repo.yaml`, `sous.repo.yml` or `sous.repo.json`. Exactly one of
|
|
62
|
-
those, never two.
|
|
63
|
-
|
|
64
38
|
```yaml
|
|
65
|
-
formatVersion: 1
|
|
66
|
-
|
|
67
|
-
# A suggested short name. A project records the name it actually uses when the
|
|
68
|
-
# repository is added, so two repositories suggesting the same name never collide.
|
|
69
|
-
name: sous-recipes
|
|
70
|
-
|
|
71
|
-
description: The official sous recipe repository.
|
|
72
|
-
|
|
73
|
-
# Surfaced when a provider cannot support `sous repo submit`, so a contributor is
|
|
74
|
-
# never left without a route. A URL or plain prose.
|
|
75
|
-
contribute: https://github.com/sous-io/sous-recipes/blob/main/CONTRIBUTING.md
|
|
76
|
-
|
|
39
|
+
formatVersion: 1 # every format on this page carries it
|
|
40
|
+
name: qa-recipes # suggested short name; a project records its own
|
|
77
41
|
namespaces:
|
|
78
|
-
core:
|
|
79
|
-
description: Skills that teach agents about sous itself.
|
|
80
42
|
workflow:
|
|
81
|
-
description:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
recipes:
|
|
85
|
-
- recipes/
|
|
86
|
-
- recipes/
|
|
43
|
+
description: Recipes about how work moves through a project.
|
|
44
|
+
quality:
|
|
45
|
+
description: Recipes that exercise the edges.
|
|
46
|
+
recipes: # every recipe folder, relative to the repository root
|
|
47
|
+
- recipes/workflow/qa-variables
|
|
48
|
+
- recipes/quality/qa-remote-dep
|
|
87
49
|
```
|
|
88
50
|
|
|
89
|
-
| Field | Required |
|
|
90
|
-
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `namespaces` | yes | map of name to `{ description? }` | Every namespace the repository publishes |
|
|
96
|
-
| `recipes` | yes | list of relative paths | Every recipe folder, each holding a recipe manifest |
|
|
97
|
-
|
|
98
|
-
Recipe paths stay inside the repository: an absolute path, a backslash, a `..` segment or a
|
|
99
|
-
trailing slash is rejected.
|
|
51
|
+
| Field | Required | Notes |
|
|
52
|
+
|-------|----------|-------|
|
|
53
|
+
| `name` | yes | A suggestion only; `sous repo add --name` decides what a project calls it |
|
|
54
|
+
| `description`, `contribute` | no | A summary for anyone reading the repository, and where to send a contribution when a provider cannot support `sous repo submit` |
|
|
55
|
+
| `namespaces` | yes | Keyed by namespace name; each entry takes an optional `description` |
|
|
56
|
+
| `recipes` | yes | Relative paths, each holding a recipe manifest; a path listed twice is an error |
|
|
100
57
|
|
|
101
58
|
## `sous.recipe.yaml`: the recipe manifest
|
|
102
59
|
|
|
103
|
-
One recipe, complete. It lives in the recipe's own folder, as `sous.recipe.yaml`,
|
|
104
|
-
`sous.recipe.yml` or `sous.recipe.json`.
|
|
105
|
-
|
|
106
60
|
```yaml
|
|
107
61
|
formatVersion: 1
|
|
108
|
-
namespace:
|
|
109
|
-
name:
|
|
110
|
-
version: 1.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
#
|
|
115
|
-
# A bare ref names a recipe in this same repository.
|
|
116
|
-
depends:
|
|
117
|
-
- workflow/sat
|
|
118
|
-
|
|
119
|
-
# Co-subscriptions. Subscribing to this recipe subscribes the project to these too,
|
|
120
|
-
# with full semantics: their questions run and their files DO enter the output.
|
|
121
|
-
# A curated bundle is simply a recipe made mostly of these. A locator URL names a
|
|
122
|
-
# recipe in another repository.
|
|
123
|
-
subscribes:
|
|
124
|
-
- github://sous-io/sous-recipes/communication/control-flow@^2.0.0
|
|
125
|
-
|
|
126
|
-
# The files this recipe contributes. Patterns are relative to the recipe folder.
|
|
127
|
-
contents:
|
|
62
|
+
namespace: quality
|
|
63
|
+
name: qa-remote-dep
|
|
64
|
+
version: 0.1.0
|
|
65
|
+
depends: # 'subscribes' takes the same two spellings
|
|
66
|
+
- workflow/qa-helper
|
|
67
|
+
- github://sous-io/sous-recipes/workflow/sub-agent-delegation@^1.0
|
|
68
|
+
contents: # what a subscriber actually receives
|
|
128
69
|
- kind: skills
|
|
129
|
-
include:
|
|
130
|
-
- skills/**/*.md
|
|
131
|
-
exclude:
|
|
132
|
-
- skills/**/draft-*.md
|
|
133
|
-
- kind: memories
|
|
134
|
-
include:
|
|
135
|
-
- memories/*.md
|
|
136
|
-
- kind: config
|
|
137
|
-
include:
|
|
138
|
-
- config/510-task-files.json
|
|
139
|
-
|
|
140
|
-
# Published specifications, not values. A question is asked only when a subscribed
|
|
141
|
-
# recipe needs the variable and no valid answer is already in scope.
|
|
142
|
-
variables:
|
|
143
|
-
- name: taskFileRoot
|
|
144
|
-
env: SOUS_VAR_TASK_FILE_ROOT
|
|
145
|
-
type: path
|
|
146
|
-
prompt: Where should task files live?
|
|
147
|
-
description: >-
|
|
148
|
-
One markdown file per git branch is written here. Most projects keep these
|
|
149
|
-
as local working notes and gitignore the directory.
|
|
150
|
-
example: .sous/tasks
|
|
151
|
-
default: .sous/tasks
|
|
152
|
-
required: true
|
|
153
|
-
scope: shared
|
|
154
|
-
|
|
155
|
-
- name: ticketSystem
|
|
156
|
-
type: enum
|
|
157
|
-
prompt: Which ticket system do you use?
|
|
158
|
-
description: >-
|
|
159
|
-
Decides which ticket identifiers the skills expect and which links they
|
|
160
|
-
write into a task file.
|
|
161
|
-
example: github
|
|
162
|
-
default: github
|
|
163
|
-
validate:
|
|
164
|
-
enum: [github, jira, linear]
|
|
70
|
+
include: [skills/**/*.md] # 'exclude' is the optional counterpart
|
|
165
71
|
```
|
|
166
72
|
|
|
167
|
-
|
|
73
|
+
| Field | Required | Notes |
|
|
74
|
+
|-------|----------|-------|
|
|
75
|
+
| `namespace`, `name` | yes | The namespace must be declared by the repository manifest; the name is unique within it |
|
|
76
|
+
| `version` | yes | An exact semantic version; the manifest is the source of truth and the git tag follows it |
|
|
77
|
+
| `description` | no | Copied into the index at release time; shown by `sous recipe list` and `sous repo search` |
|
|
78
|
+
| `depends`, `subscribes` | no | Build dependencies and co-subscriptions; within either list, no entry may repeat |
|
|
79
|
+
| `contents`, `variables` | no | Contents default to an empty list, which is what a curated bundle publishes; a duplicated variable name, or two definitions claiming one `env`, is an error |
|
|
168
80
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
| `namespace` | yes | kebab-case string | Must be declared by the repository manifest |
|
|
173
|
-
| `name` | yes | kebab-case string | Unique within its namespace |
|
|
174
|
-
| `version` | yes | exact semantic version | Never a range; `1.2.0`, or `2.0.0-beta.1` for a prerelease |
|
|
175
|
-
| `description` | no | string | Shown by `sous repo search` and `sous repo list` |
|
|
176
|
-
| `depends` | no | list of dependencies | Build dependencies, named by location |
|
|
177
|
-
| `subscribes` | no | list of dependencies | Co-subscriptions, named by location |
|
|
178
|
-
| `contents` | no | list of content groups | Defaults to an empty list, which is what a curated bundle wants |
|
|
179
|
-
| `variables` | no | list of variable definitions | Published specifications |
|
|
180
|
-
|
|
181
|
-
Recipe metadata is the source of truth for versions. A git tag shaped
|
|
182
|
-
`namespace/recipe@1.2.3` is a convenience ref that `sous repo release` keeps consistent with the
|
|
183
|
-
`version` field; a missing or wrong tag is reported rather than silently hiding a version.
|
|
81
|
+
`contents[].kind` is `skills`, `memories`, `prompts` or `config`. The first three are written to the
|
|
82
|
+
destinations named by [`recipeOutputs`](#recipeoutputs-where-the-files-land); `config` entries become config
|
|
83
|
+
layers instead. `include` needs at least one glob, and both lists are recipe-relative.
|
|
184
84
|
|
|
185
85
|
### Dependencies named by location
|
|
186
86
|
|
|
187
|
-
`depends` and `subscribes`
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
```
|
|
87
|
+
`depends` and `subscribes` share one grammar in two spellings: `workflow/qa-helper` is a sibling in this
|
|
88
|
+
same repository, and `github://sous-io/sous-recipes/workflow/sub-agent-delegation@^1.0` is a recipe in
|
|
89
|
+
another one. A locator URL's scheme is the provider's identifier (`github` and `gitlab` ship today), and its
|
|
90
|
+
path is read from the RIGHT: the last two segments are the namespace and the recipe, and everything before
|
|
91
|
+
them names the repository, whose first segment is the host when it carries a dot and otherwise the
|
|
92
|
+
provider's public host, so `gitlab://gitlab.example.com/group/subgroup/project/workflow/task-files`
|
|
93
|
+
resolves. A trailing `.git` is dropped and at most one `@` range may follow. A manifest may write no `repo:`
|
|
94
|
+
qualifier, no `local://` locator (a local repository is a convenience, not a published location) and no
|
|
95
|
+
filesystem path such as `../qa-helper`.
|
|
197
96
|
|
|
198
|
-
|
|
199
|
-
tags in one run and records the exact version in the index, so the pairing is fixed forever.
|
|
97
|
+
### Variable definitions
|
|
200
98
|
|
|
201
|
-
|
|
99
|
+
A definition is a specification, not a value; [Recipe variables](repositories-variables.md) covers how an
|
|
100
|
+
answer is found and stored, and a worked pair of definitions, including a secret, is in [Declare the
|
|
101
|
+
variables a recipe needs](repositories-authoring.md#declare-the-variables-a-recipe-needs).
|
|
202
102
|
|
|
203
103
|
```yaml
|
|
204
|
-
|
|
205
|
-
-
|
|
206
|
-
|
|
104
|
+
variables:
|
|
105
|
+
- name: qaTaskRoot
|
|
106
|
+
type: path
|
|
107
|
+
prompt: Where should the review notes be stored?
|
|
108
|
+
description: One review note per branch is read from and written to this directory.
|
|
109
|
+
example: ~/qa-notes
|
|
110
|
+
default: .sous/qa-notes
|
|
207
111
|
```
|
|
208
112
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
without any extra syntax.
|
|
219
|
-
- The **range after `@` is optional**, and follows npm's rules like every other range.
|
|
220
|
-
|
|
221
|
-
Two things a manifest may not write:
|
|
113
|
+
| Field | Required | Notes |
|
|
114
|
+
|-------|----------|-------|
|
|
115
|
+
| `name`, `type` | yes | camelCase, as templates refer to it; one of `string`, `number`, `boolean`, `enum`, `path` or `url` |
|
|
116
|
+
| `prompt`, `description` | yes | The one-line question, and the paragraph explaining the variable; both are shown when sous asks and by `sous vars show <name>` |
|
|
117
|
+
| `example` | yes | A realistic sample answer; documentation only, never stored and never a fallback. Use `default` for the value offered when nothing else is in scope |
|
|
118
|
+
| `env` | no | The environment variable an answer binds to; omitted, it is `SOUS_VAR_` plus the upper snake case form of the variable's own name, so `qaScratchDir` binds to `SOUS_VAR_QA_SCRATCH_DIR` |
|
|
119
|
+
| `required`, `secret` | no | Default to `true` and `false`; a secret is always written to the gitignored `.sous/.env.local` |
|
|
120
|
+
| `scope` | no | `shared` (the committed `.sous/.env`) or `local` (the gitignored `.sous/.env.local`); defaults to `shared` |
|
|
121
|
+
| `validate` | no | `pattern`, `minLength`, `maxLength`, `min`, `max`, `enum` |
|
|
222
122
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
a repository, and no other project has to agree with it.
|
|
227
|
-
|
|
228
|
-
A consumer matches a locator against the repositories it has added by their canonical identity
|
|
229
|
-
(`github.com/sous-io/sous-recipes`), so a project that added the same repository under a
|
|
230
|
-
different short name resolves against the copy it already has. One it has not added goes through
|
|
231
|
-
the ordinary trust round, which can show the URL the dependency named.
|
|
232
|
-
|
|
233
|
-
### Content groups
|
|
234
|
-
|
|
235
|
-
| Field | Required | Type | Notes |
|
|
236
|
-
|-------|----------|------|-------|
|
|
237
|
-
| `kind` | yes | `skills`, `memories`, `prompts` or `config` | Decides where the files land in a subscribing project |
|
|
238
|
-
| `include` | yes | list of glob patterns | At least one; relative to the recipe folder |
|
|
239
|
-
| `exclude` | no | list of glob patterns | Removed from the include set |
|
|
240
|
-
|
|
241
|
-
`config` entries name config layer files that are merged into the subscriber's config. Like
|
|
242
|
-
recipe paths, include and exclude patterns may not escape the recipe folder.
|
|
243
|
-
|
|
244
|
-
### Variable definitions
|
|
123
|
+
At publish time: a `type: enum` variable must list its options under `validate.enum`; `minLength` cannot
|
|
124
|
+
exceed `maxLength`, nor `min` exceed `max`; `default` and `example` must match the type and, for an enum, be
|
|
125
|
+
one of its options; `secret: true` with `scope: shared` is refused; the committed env file would leak it.
|
|
245
126
|
|
|
246
|
-
|
|
247
|
-
|-------|----------|------|-------|
|
|
248
|
-
| `name` | yes | camelCase string | How templates refer to the variable |
|
|
249
|
-
| `env` | no | upper snake case string | The environment variable an answer binds to. `sous repo release` derives a default when it is omitted; the runtime never derives one |
|
|
250
|
-
| `type` | yes | `string`, `number`, `boolean`, `enum`, `path` or `url` | How the answer is validated and prompted for |
|
|
251
|
-
| `prompt` | yes | string | The one-line question text |
|
|
252
|
-
| `description` | yes | non-empty string | The paragraph explaining what the variable is for. Shown above the question when sous asks, and by `sous vars show <name>` |
|
|
253
|
-
| `example` | yes | string, number or boolean | A realistic sample answer, shown with the question. Checked against the declared type and the enum options exactly as `default` is, but never stored and never offered as the answer |
|
|
254
|
-
| `default` | no | string, number or boolean | Must match the declared type, and for an enum must be one of the options |
|
|
255
|
-
| `required` | no | boolean, default `true` | Whether a build needs an answer |
|
|
256
|
-
| `secret` | no | boolean, default `false` | A secret is always stored in the gitignored `.sous/.env.local` |
|
|
257
|
-
| `scope` | no | `shared` or `local`, default `shared` | Which env file the answer is written to |
|
|
258
|
-
| `validate` | no | object | Constraints, below |
|
|
127
|
+
## `sous.index.json`
|
|
259
128
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
only, so it shows what a real answer looks like without sous ever storing it; a `default` is a real
|
|
263
|
-
value, offered as the answer when nothing else is in scope. A variable whose sample answer is
|
|
264
|
-
genuinely the right starting value carries the same text in both.
|
|
265
|
-
|
|
266
|
-
`scope: shared` writes to `.sous/.env`, which is committed and shared with the team.
|
|
267
|
-
`scope: local` writes to `.sous/.env.local`, which is gitignored and machine-specific. A secret
|
|
268
|
-
declared as shared is rejected, because that combination would commit the secret.
|
|
269
|
-
|
|
270
|
-
Constraints under `validate`:
|
|
271
|
-
|
|
272
|
-
| Field | Type | Applies to |
|
|
273
|
-
|-------|------|-----------|
|
|
274
|
-
| `pattern` | string holding a regular expression | String-like answers |
|
|
275
|
-
| `minLength`, `maxLength` | whole numbers | String-like answers |
|
|
276
|
-
| `min`, `max` | numbers | Numeric answers |
|
|
277
|
-
| `enum` | list of strings | Required when `type` is `enum` |
|
|
278
|
-
|
|
279
|
-
A `pattern` runs under a time budget, on a worker rather than on the thread waiting for the
|
|
280
|
-
answer; a pattern that exceeds the budget fails validation, and the failure names the pattern
|
|
281
|
-
rather than blaming the answer.
|
|
282
|
-
|
|
283
|
-
!> A schema may only LOOSEN within a major version. Tightening a constraint is a major bump,
|
|
284
|
-
and an upgrade re-validates stored answers, re-prompting only where an old answer no longer
|
|
285
|
-
fits.
|
|
286
|
-
|
|
287
|
-
### Unknown keys
|
|
288
|
-
|
|
289
|
-
Both hand-written manifests reject an unknown key and report it as a likely typo, naming the
|
|
290
|
-
file and the field's path. The one exception is the reserved `x-` namespace: a key such as
|
|
291
|
-
`x-team` is accepted and ignored, so a repository can carry metadata sous knows nothing about.
|
|
292
|
-
|
|
293
|
-
### JSON manifests
|
|
294
|
-
|
|
295
|
-
A `.json` manifest is read with a permissive parser: line comments, block comments and trailing
|
|
296
|
-
commas are all allowed, so a manifest can explain itself.
|
|
129
|
+
Machine-written by `sous repo release` and committed beside the recipes it describes. Adding a repository
|
|
130
|
+
fetches only this file; nothing more is downloaded until a project subscribes.
|
|
297
131
|
|
|
298
132
|
```json
|
|
299
133
|
{
|
|
300
|
-
|
|
301
|
-
"
|
|
302
|
-
"
|
|
303
|
-
"
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
134
|
+
"formatVersion": 1, "name": "qa-recipes", "generator": "0.2.0",
|
|
135
|
+
"generatedAt": "2026-09-12T07:06:33.236Z",
|
|
136
|
+
"namespaces": { "quality": { "description": "Recipes that exercise the edges." } },
|
|
137
|
+
"recipes": { "quality/qa-remote-dep": { "path": "recipes/quality/qa-remote-dep",
|
|
138
|
+
"versions": { "0.1.0": {
|
|
139
|
+
"dependencies": { "workflow/qa-helper": { "version": "0.1.0" },
|
|
140
|
+
"workflow/sub-agent-delegation": { "range": "^1.0", "repo": "github.com/sous-io/sous-recipes" } },
|
|
141
|
+
"hash": "sha256-46e75442aeb368116c8830ad382707759f1ecd03974c4b5d42a5fabffce0324f",
|
|
142
|
+
"prerelease": false, "releasedAt": "2026-09-12T07:06:33.236Z",
|
|
143
|
+
"tag": "quality/qa-remote-dep@0.1.0"
|
|
144
|
+
} } } }
|
|
308
145
|
}
|
|
309
146
|
```
|
|
310
147
|
|
|
311
|
-
|
|
148
|
+
| Field | Where | Notes |
|
|
149
|
+
|-------|-------|-------|
|
|
150
|
+
| `generatedAt`, `generator`, `name`, `namespaces` | top level | When the index was generated and by which sous version, then the name and namespaces copied from the repository manifest. `$comment` is free text, since JSON has no comments, and is ignored on a published index |
|
|
151
|
+
| `recipes` | top level | Keyed `namespace/recipe`; a recipe whose namespace is not declared is an error. Each entry carries `path` (the recipe folder), `description`, and `versions`, keyed by exact version |
|
|
152
|
+
| `hash`, `tag` | per version | The content hash verified after every fetch, and the tag, which must be exactly `namespace/recipe@version` so a version can never point at a branch |
|
|
153
|
+
| `prerelease`, `releasedAt`, `seeded` | per version | Whether ranges skip it unless a subscription opts in, when it was released, and whether it is the copy sous folds in from its own package |
|
|
312
154
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
155
|
+
Dependencies are resolved at release time and keyed `namespace/recipe`. An entry carries `version` (a
|
|
156
|
+
sibling, resolved exactly) or `range` plus `repo` (the identity of the repository publishing it, whose own
|
|
157
|
+
index resolves the range); at least one is required. Installing a version installs these rather than
|
|
158
|
+
re-resolving the manifest's ranges.
|
|
317
159
|
|
|
318
|
-
|
|
319
|
-
|
|
160
|
+
**The `seeded` field.** Sous ships the `core` recipe in its own npm package, so a project can build before
|
|
161
|
+
reaching the network. That copy is folded into the official repository's index in memory and resolved like
|
|
162
|
+
any other, marked `seeded: true`; if the repository already publishes it, the published copy wins. Sous
|
|
163
|
+
never writes `seeded` onto a fetched index, and when it writes the placeholder into the index cache it
|
|
164
|
+
stamps a fixed `$comment`, so a later run can tell its own placeholder from a real index and replace it.
|
|
320
165
|
|
|
321
|
-
|
|
322
|
-
{
|
|
323
|
-
"formatVersion": 1,
|
|
324
|
-
"name": "sous-recipes",
|
|
325
|
-
"generatedAt": "2026-09-09T14:03:11.482Z",
|
|
326
|
-
"generator": "0.2.0",
|
|
327
|
-
"namespaces": {
|
|
328
|
-
"core": { "description": "Skills that teach agents about sous itself." },
|
|
329
|
-
"workflow": { "description": "Task tracking and branch workflow." }
|
|
330
|
-
},
|
|
331
|
-
"recipes": {
|
|
332
|
-
"workflow/task-files": {
|
|
333
|
-
"path": "recipes/workflow/task-files",
|
|
334
|
-
"description": "Per-branch task files.",
|
|
335
|
-
"versions": {
|
|
336
|
-
"1.0.0": {
|
|
337
|
-
"hash": "sha256-3b1f...c9",
|
|
338
|
-
"tag": "workflow/task-files@1.0.0",
|
|
339
|
-
"prerelease": false,
|
|
340
|
-
"releasedAt": "2026-08-01T09:00:00.000Z",
|
|
341
|
-
"dependencies": {
|
|
342
|
-
"workflow/sat": { "version": "1.4.0" },
|
|
343
|
-
"communication/control-flow": {
|
|
344
|
-
"repo": "github.com/sous-io/sous-recipes",
|
|
345
|
-
"range": "^2.0.0"
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
},
|
|
349
|
-
"1.1.0-beta.1": {
|
|
350
|
-
"hash": "sha256-77ad...20",
|
|
351
|
-
"tag": "workflow/task-files@1.1.0-beta.1",
|
|
352
|
-
"prerelease": true
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
| Field | Required | Type | Notes |
|
|
361
|
-
|-------|----------|------|-------|
|
|
362
|
-
| `formatVersion` | yes | `1` | The on-disk format version |
|
|
363
|
-
| `name` | yes | kebab-case string | The repository's suggested short name |
|
|
364
|
-
| `generatedAt` | yes | ISO 8601 timestamp | When the index was generated |
|
|
365
|
-
| `generator` | yes | exact semantic version | The version of sous that generated it |
|
|
366
|
-
| `namespaces` | yes | map of name to `{ description? }` | Copied from the repository manifest |
|
|
367
|
-
| `recipes` | yes | map of `namespace/recipe` to a recipe entry | Every recipe published |
|
|
368
|
-
| `$comment` | no | string | A note about where this copy came from. JSON has no comment syntax, and an index is machine-written, so this is the one place a writer can say something to whoever opens the file. Sous ignores it, with one exception: the seed index below |
|
|
369
|
-
|
|
370
|
-
A recipe entry holds `path`, an optional `description`, and `versions`: a map from an exact
|
|
371
|
-
version to `{ hash, tag, prerelease, releasedAt?, dependencies?, seeded? }`. Every recipe needs
|
|
372
|
-
at least one version, and its namespace must be one the index declares. `seeded` is never
|
|
373
|
-
written by `sous repo release`; it marks the packaged core version sous folds in itself, and is
|
|
374
|
-
described under the seed index below.
|
|
375
|
-
|
|
376
|
-
### Resolved dependencies
|
|
377
|
-
|
|
378
|
-
`dependencies` records what one exact version was released against, keyed `namespace/recipe`. It
|
|
379
|
-
is why a published version means one thing forever: a consumer installing `1.0.0` installs the
|
|
380
|
-
versions `1.0.0` was published with, rather than re-resolving its ranges months later.
|
|
381
|
-
|
|
382
|
-
| Field | When | Notes |
|
|
383
|
-
|-------|------|-------|
|
|
384
|
-
| `version` | a sibling | The exact version, settled when both tags were cut |
|
|
385
|
-
| `repo` | another repository | That repository's canonical identity, `<host>/<owner path>/<name>` |
|
|
386
|
-
| `range` | another repository | The range the manifest declared; its exact version lives in that repository's own index |
|
|
387
|
-
|
|
388
|
-
The field is additive: an index written before it existed still parses, and a consumer that
|
|
389
|
-
finds no entry falls back to the ranges the recipe's manifest declares. `formatVersion` stays
|
|
390
|
-
`1`.
|
|
391
|
-
|
|
392
|
-
A version's `tag` must be exactly `<namespace>/<recipe>@<version>` for the entry it sits under,
|
|
393
|
-
and an index that says otherwise is refused when it is read. The tag is what the provider
|
|
394
|
-
fetches, so an index publishing `1.0.0` with `tag: "main"` would point a pinned version at a
|
|
395
|
-
branch: the content behind it changes on every push and the pinned hash then simply starts
|
|
396
|
-
failing, with nothing on the consumer's side able to say why.
|
|
397
|
-
|
|
398
|
-
Content hashes are written as `sha256-` followed by 64 lowercase hexadecimal characters. The
|
|
399
|
-
hash of a version is checked after every fetch, and against the lockfile before a cached copy is
|
|
400
|
-
used.
|
|
166
|
+
## `.sous/sous.lock.json`
|
|
401
167
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
One index is not published by any repository: sous writes a stand-in index for its own
|
|
405
|
-
`sous-recipes` entry, into the index cache, when nothing real has ever been fetched from it. It
|
|
406
|
-
lists exactly one recipe, `core/sous-skills`, at the version of the running sous, with the hash
|
|
407
|
-
of the copy that was just seeded out of the installed package. That is what lets a project
|
|
408
|
-
resolve the core namespace on a machine that has never had a network connection.
|
|
409
|
-
|
|
410
|
-
The stand-in says so in its `$comment`, which is how a later run recognizes its own placeholder
|
|
411
|
-
and is willing to replace it; an index a repository actually published is never overwritten. No
|
|
412
|
-
freshness sidecar is written beside it, so the very first command that does have a network
|
|
413
|
-
fetches the real index rather than waiting out a window the stand-in never earned. When there is
|
|
414
|
-
still no network, sous reports that it could not check and uses the stand-in, which is the same
|
|
415
|
-
last-good behavior every repository gets.
|
|
416
|
-
|
|
417
|
-
### The packaged core version
|
|
418
|
-
|
|
419
|
-
The stand-in covers a machine that has never fetched anything. A machine that has been using
|
|
420
|
-
sous for a while holds the repository's real index instead, and that index publishes whatever
|
|
421
|
-
core versions the release pipeline has cut so far. Upgrade sous and the version the built-in
|
|
422
|
-
`core` subscription asks for is, for a while, not among them.
|
|
423
|
-
|
|
424
|
-
So sous folds the packaged version into that index IN MEMORY whenever the index does not carry
|
|
425
|
-
it: one more entry under `core/sous-skills`, at the running sous version, with the hash of the
|
|
426
|
-
copy just seeded out of the package, `prerelease` set from the version itself, and `seeded` set
|
|
427
|
-
to `true`. Nothing is written to disk; the cached file stays exactly what the repository served,
|
|
428
|
-
so the next real fetch is compared against the truth. The moment the repository does publish
|
|
429
|
-
that version, its own entry is what gets used, and if the two disagree about the content hash
|
|
430
|
-
sous says so once and prefers the published one.
|
|
431
|
-
|
|
432
|
-
## `sous.lock.json`: the project lockfile
|
|
433
|
-
|
|
434
|
-
Machine-written into the project's `.sous/` directory, and committed. The lockfile records the
|
|
435
|
-
exact version and content hash of everything the project currently uses, so a fresh clone
|
|
436
|
-
restores deterministically with no prompts and no version drift. Together with repository trust
|
|
437
|
-
it is the supply-chain defense: nothing new enters a project except through an explicit, visible
|
|
438
|
-
change to these files.
|
|
168
|
+
Machine-written, committed, and the reason a fresh clone restores with no prompts and no drift.
|
|
439
169
|
|
|
440
170
|
```json
|
|
441
171
|
{
|
|
442
172
|
"formatVersion": 1,
|
|
443
|
-
"repos": {
|
|
444
|
-
"sous-recipes": {
|
|
445
|
-
"url": "https://github.com/sous-io/sous-recipes",
|
|
446
|
-
"identity": "github.com/sous-io/sous-recipes",
|
|
447
|
-
"indexHash": "sha256-91cc...4e"
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
173
|
"recipes": {
|
|
451
|
-
"
|
|
452
|
-
"
|
|
453
|
-
|
|
454
|
-
"hash": "sha256-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
},
|
|
458
|
-
"workflow/task-files": {
|
|
459
|
-
"repo": "sous-recipes",
|
|
460
|
-
"version": "1.2.0",
|
|
461
|
-
"hash": "sha256-3b1f...c9",
|
|
462
|
-
"requestedBy": ["project"],
|
|
463
|
-
"kind": "subscribes"
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
| Field | Required | Type | Notes |
|
|
470
|
-
|-------|----------|------|-------|
|
|
471
|
-
| `formatVersion` | yes | `1` | The on-disk format version |
|
|
472
|
-
| `repos` | yes | map of short name to `{ url, identity, indexHash? }` | Every repository the locked recipes came from |
|
|
473
|
-
| `recipes` | yes | map of `namespace/recipe` to a locked entry | Everything currently in use |
|
|
474
|
-
|
|
475
|
-
A locked entry holds `repo` (which must appear under `repos`), the exact `version` resolved, its
|
|
476
|
-
`hash`, a `requestedBy` list and a `kind` of `subscribes` or `depends`.
|
|
477
|
-
|
|
478
|
-
A repository appears twice over, and the two are for different readers. The KEY is your
|
|
479
|
-
project's short name, which is what every message and every recipe entry names. The `identity`
|
|
480
|
-
is the canonical location it was derived from, and it is what the machine-wide store and the
|
|
481
|
-
index cache file that repository under, so two projects that call the same repository different
|
|
482
|
-
things still share one cached copy.
|
|
483
|
-
|
|
484
|
-
`requestedBy` is what makes removal safe. Every entry lists who holds it: the literal string
|
|
485
|
-
`project` for something the project subscribed to directly, or a recipe key for something pulled
|
|
486
|
-
in as a dependency. Unsubscribing removes one holder, and the entry itself goes only when the
|
|
487
|
-
last holder does.
|
|
488
|
-
|
|
489
|
-
Every key is written sorted, so a regenerated lockfile changes only when its content genuinely
|
|
490
|
-
does.
|
|
491
|
-
|
|
492
|
-
## `.sous.entry.json`: the store entry marker
|
|
493
|
-
|
|
494
|
-
The machine-wide store lives under the user-level sous directory, one folder per recipe version:
|
|
495
|
-
`$SOUS_HOME/cache/<repository identity>/<namespace>/<recipe>/<version>/`. A marker beside each
|
|
496
|
-
one makes the entry self-describing, so the store can be verified and swept without consulting
|
|
497
|
-
any project.
|
|
498
|
-
|
|
499
|
-
```json
|
|
500
|
-
{
|
|
501
|
-
"formatVersion": 1,
|
|
502
|
-
"repo": "github.com/sous-io/sous-recipes",
|
|
503
|
-
"namespace": "workflow",
|
|
504
|
-
"name": "task-files",
|
|
505
|
-
"version": "1.2.0",
|
|
506
|
-
"hash": "sha256-3b1f...c9",
|
|
507
|
-
"fetchedAt": "2026-09-01T10:00:00.000Z",
|
|
508
|
-
"lastAccessAt": "2026-09-09T14:03:11.482Z",
|
|
509
|
-
"sizeBytes": 20480
|
|
174
|
+
"quality/qa-remote-dep": { "kind": "subscribes", "repo": "qa-recipes", "version": "0.1.0",
|
|
175
|
+
"requestedBy": ["project"], "hash": "sha256-46e75442aeb368116c8830ad38...ffce0324f" },
|
|
176
|
+
"workflow/qa-helper": { "kind": "depends", "repo": "qa-recipes", "version": "0.1.0",
|
|
177
|
+
"requestedBy": ["quality/qa-remote-dep"], "hash": "sha256-78660ab9889e707a38be...b2c69698d" }
|
|
178
|
+
},
|
|
179
|
+
"repos": { "qa-recipes": { "identity": "localhost/home/me/projects/qa", "url": "/home/me/Projects/qa" } }
|
|
510
180
|
}
|
|
511
181
|
```
|
|
512
182
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
the
|
|
183
|
+
| Field | Notes |
|
|
184
|
+
|-------|-------|
|
|
185
|
+
| `repos.<name>.url`, `.identity`, `.indexHash` | Where the repository lives, as recorded when it was added; the canonical identity the store and index cache file it under; and the hash of the index this lock was resolved against, when known |
|
|
186
|
+
| `recipes.<key>.repo`, `.version`, `.hash` | The project's short name for the repository, which must appear under `repos`; the version resolved; and the hash verified against the store after every fetch |
|
|
187
|
+
| `recipes.<key>.requestedBy`, `.kind` | Who holds the entry, the literal `project` or the key of a recipe that requires it, and whether that holder is a `subscribes` or a `depends` |
|
|
517
188
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
189
|
+
A repository appears under two names on purpose: the key is the project's own short name, which every
|
|
190
|
+
message uses, while `identity` is what the store is keyed by, so two projects with different labels share
|
|
191
|
+
one cached copy. `identity` is optional on read only, for older lockfiles, and is derived from the URL on
|
|
192
|
+
the next write. `requestedBy` makes removal safe; an entry goes when its last holder does.
|
|
521
193
|
|
|
522
194
|
## The store on disk
|
|
523
195
|
|
|
524
|
-
The store is
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
`.sous/.env`) as well as in the shell. The same directory holds globally linked checkouts
|
|
528
|
-
(`repos/`) and the machine-wide links map.
|
|
196
|
+
The store is machine-wide, immutable per version, and disposable (everything is re-fetchable from a
|
|
197
|
+
lockfile's pins); every entry carries a `.sous.entry.json` marker. It sits under `$SOUS_HOME`, default
|
|
198
|
+
`~/.sous`.
|
|
529
199
|
|
|
530
200
|
```text
|
|
531
201
|
$SOUS_HOME/
|
|
532
|
-
cache
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
repos/<owner>/<repo>/ checkouts linked with --global
|
|
538
|
-
sous.links.json the machine-wide links map
|
|
202
|
+
cache/<repository identity>/<namespace>/<name>/<version>/ .sous.entry.json, then the files
|
|
203
|
+
cache/_indexes/<repository identity>.json the cached index
|
|
204
|
+
cache/_indexes/<repository identity>.meta.json etag, fetchedAt, lastCheckedAt, ref
|
|
205
|
+
repos/ checkouts cloned by 'sous repo link --global'
|
|
206
|
+
sous.links.json the machine-wide links map
|
|
539
207
|
```
|
|
540
208
|
|
|
541
|
-
|
|
542
|
-
several directories deep on its own: `github.com/sous-io/sous-recipes/`, with the namespace, the
|
|
543
|
-
recipe and the version following it. Keying by location rather than by
|
|
544
|
-
a short name is what lets two projects that call a repository different things share one cached
|
|
545
|
-
copy, and stops two projects that use the same short name for different repositories from
|
|
546
|
-
colliding. Nothing migrates a store keyed another way: entries sous cannot find are simply
|
|
547
|
-
fetched again, which costs a download and nothing else.
|
|
548
|
-
|
|
549
|
-
An entry is written atomically: sous copies the fetched files into a temporary directory
|
|
550
|
-
beside the entry's final home, hashes them, checks the hash against the pin it was given,
|
|
551
|
-
writes the marker, and only then renames the directory into place. A published version is
|
|
552
|
-
immutable, so re-storing one is allowed only when the content hashes the same; different
|
|
553
|
-
content under a version already in the store is an error rather than a silent overwrite.
|
|
554
|
-
|
|
555
|
-
The content hash is SHA-256 over a canonical serialization of the folder: files in bytewise
|
|
556
|
-
order of their relative paths, each contributing its path, its byte length and its bytes.
|
|
557
|
-
File modes, owners and timestamps are excluded, so the same recipe hashes the same after a
|
|
558
|
-
copy, a clone or an archive round-trip. `.git` and the entry's own `.sous.entry.json` are
|
|
559
|
-
skipped, which is what lets sous touch the marker without invalidating the entry. Every read
|
|
560
|
-
re-verifies the hash; an entry that no longer matches is removed and re-fetched, and the
|
|
561
|
-
build says so.
|
|
562
|
-
|
|
563
|
-
Symbolic links are skipped entirely, by the hash and by the copy into the store alike, and so
|
|
564
|
-
is anything under a linked directory. A link points at bytes the repository does not own, so
|
|
565
|
-
following one would make the same published version hash differently on the publisher's
|
|
566
|
-
machine and the consumer's, and every install would then fail against its own pin.
|
|
567
|
-
`sous repo release` refuses to publish a recipe folder containing a link, naming it, so this
|
|
568
|
-
is caught where it can be fixed rather than at install time. A recipe that needs a file ships
|
|
569
|
-
the file.
|
|
570
|
-
|
|
571
|
-
Collection is size-capped and least-recently-used. `store.maxBytes` sets the cap (one
|
|
572
|
-
gigabyte by default) and `lastAccessAt` in each marker sets the order; entries a lockfile
|
|
573
|
-
still pins are never evicted, even when honoring the cap would require it. Everything in
|
|
574
|
-
the store is re-fetchable from those pins, so deleting the whole directory costs a download
|
|
575
|
-
and nothing else.
|
|
576
|
-
|
|
577
|
-
## `sous.links.json`: the links map
|
|
578
|
-
|
|
579
|
-
A link redirects a repository's resolution away from the store and at a real working copy, which
|
|
580
|
-
is how a maintainer edits recipes: edits happen in a checkout, never in the store.
|
|
209
|
+
The marker makes an entry self-describing, so the store is sweepable without consulting a project:
|
|
581
210
|
|
|
582
211
|
```json
|
|
583
|
-
{
|
|
584
|
-
"
|
|
585
|
-
"
|
|
586
|
-
|
|
587
|
-
"path": "/home/me/Projects/sous-recipes",
|
|
588
|
-
"linkedAt": "2026-09-09T14:03:11.482Z",
|
|
589
|
-
"origin": "clone"
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
212
|
+
{ "formatVersion": 1, "repo": "github.com/sous-io/sous-recipes", "namespace": "workflow",
|
|
213
|
+
"name": "sub-agent-delegation", "version": "1.0.0", "sizeBytes": 3032,
|
|
214
|
+
"hash": "sha256-6841b6f61a9a62d84152bc0fcfe246af35b7f75001527d6addf55e51068f8387",
|
|
215
|
+
"fetchedAt": "2026-09-12T07:06:57.123Z", "lastAccessAt": "2026-09-12T07:06:57.292Z" }
|
|
593
216
|
```
|
|
594
217
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
A link entry holds an absolute `path`, a `linkedAt` timestamp, and an `origin` of `clone` (sous
|
|
601
|
-
cloned the working copy itself) or `path` (sous was pointed at an existing checkout). Unlinking
|
|
602
|
-
removes the entry and leaves the checkout on disk.
|
|
603
|
-
|
|
604
|
-
Two maps are read: the project's `.sous/sous.links.json` and the machine-wide
|
|
605
|
-
`$SOUS_HOME/sous.links.json`, with the project map winning on conflict. The file is never
|
|
606
|
-
committed. A link bypasses versions, the lockfile and freshness checks, and those bypasses
|
|
607
|
-
belong to one person's machine rather than to the team, so builds announce a linked repository
|
|
608
|
-
loudly.
|
|
609
|
-
|
|
610
|
-
### Where a linked checkout lives
|
|
611
|
-
|
|
612
|
-
`sous repo link <repo>` with no path clones the repository for you. The working copy lands in
|
|
613
|
-
`.sous/repos/<owner>/<name>`, or in `$SOUS_HOME/repos/<owner>/<name>` with `--global`, where
|
|
614
|
-
every project on the machine shares one checkout. A directory that is already a checkout of the
|
|
615
|
-
same remote is reused rather than cloned again, so running the command twice is harmless; one
|
|
616
|
-
holding a different remote is an error, because reading the wrong recipes silently would be
|
|
617
|
-
worse than stopping.
|
|
618
|
-
|
|
619
|
-
`sous repo link <repo> <path>` links a checkout that already exists and clones nothing. The path
|
|
620
|
-
must hold a repo manifest at its root, since a directory without one is not a repository.
|
|
218
|
+
`hash` is checked against the lockfile before the entry is used, and `sizeBytes` and `lastAccessAt` drive
|
|
219
|
+
the size-capped, least-recently-used collection `sous repo gc` performs. The hash is SHA-256 over each
|
|
220
|
+
file's path and bytes in a canonical order, with `.git`, the marker and anything behind a symlink excluded,
|
|
221
|
+
so a version hashes the same on every machine.
|
|
621
222
|
|
|
622
|
-
|
|
623
|
-
accepted, but it is not a way around adding one: a linked repository's recipes are read with no
|
|
624
|
-
version, no lockfile and no hash check, so a URL the project has not added runs the same trust
|
|
625
|
-
ceremony `sous repo add` runs before anything is cloned or linked. It asks inline, `--trust`
|
|
626
|
-
acknowledges instead for a run with no terminal, and a URL whose short name already belongs to a
|
|
627
|
-
different repository is refused outright.
|
|
223
|
+
## `sous.links.json`
|
|
628
224
|
|
|
629
|
-
|
|
630
|
-
|
|
225
|
+
A link redirects a repository away from the store and at a real working copy, which is how a maintainer
|
|
226
|
+
edits recipes. It is machine-local and never committed, because a link bypasses versions, the lockfile and
|
|
227
|
+
freshness checks; entries are keyed by the repository's configured short name.
|
|
631
228
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
and linking maintains both files that do it:
|
|
636
|
-
|
|
637
|
-
- `.sous/repos/.gitignore` holds a single `*`. That covers the ignore file itself, so a cloned
|
|
638
|
-
checkout underneath it contributes nothing at all to the project's repository.
|
|
639
|
-
- `.sous/.gitignore` carries a delimited managed block:
|
|
640
|
-
|
|
641
|
-
```
|
|
642
|
-
# >>> sous managed (do not edit between these markers)
|
|
643
|
-
sous.links.json
|
|
644
|
-
sous.state.json
|
|
645
|
-
sous.pid
|
|
646
|
-
repos/
|
|
647
|
-
# <<< sous managed
|
|
229
|
+
```json
|
|
230
|
+
{ "formatVersion": 1, "links": {
|
|
231
|
+
"qa-recipes": { "path": "/home/me/Projects/qa", "origin": "path", "linkedAt": "2026-09-12T07:07:42.800Z" } } }
|
|
648
232
|
```
|
|
649
233
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
command with an error rather than a guess about where the block ends.
|
|
654
|
-
|
|
655
|
-
`sous prune` and `sous clear` only ever touch paths recorded in the state file, so nothing in
|
|
656
|
-
`.sous/repos/` is at risk from either of them.
|
|
234
|
+
`path` is absolute; `origin` is `clone` when sous cloned the working copy itself and `path` when it was
|
|
235
|
+
pointed at an existing checkout, and unlinking removes the entry while leaving either in place. Both the
|
|
236
|
+
project's `.sous/sous.links.json` and `$SOUS_HOME/sous.links.json` are read, and the project's entries win.
|
|
657
237
|
|
|
658
|
-
##
|
|
238
|
+
## Configuration keys
|
|
659
239
|
|
|
660
|
-
|
|
661
|
-
and `sous subscription add` write the first two into machine-managed `conf.d/` layers, and you may also
|
|
662
|
-
hand-write them in the primary config; the layers merge like anything else. The fourth,
|
|
663
|
-
`recipeOutputs`, is always yours to write and is covered under
|
|
664
|
-
[Consuming recipes](#consuming-recipes).
|
|
240
|
+
These live at the top level of the merged config; [The config file](configuration.md) covers the rest.
|
|
665
241
|
|
|
666
242
|
```yaml
|
|
667
|
-
# Trusted repositories, keyed by the short name refs use. Adding a repository IS
|
|
668
|
-
# trusting it, and removing the entry withdraws that trust.
|
|
669
243
|
repos:
|
|
670
|
-
|
|
671
|
-
url: https://github.com/
|
|
672
|
-
provider: github
|
|
673
|
-
enabled: true
|
|
674
|
-
alwaysPull: false
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
# What the project subscribes to, keyed by ref key.
|
|
244
|
+
sous-recipes:
|
|
245
|
+
url: https://github.com/sous-io/sous-recipes
|
|
246
|
+
provider: github # inferred from the URL when omitted; github, gitlab or local
|
|
247
|
+
enabled: true # false opts out of a repository sous provides itself
|
|
248
|
+
alwaysPull: false # install a newer in-range version rather than holding the lock
|
|
249
|
+
addedBy: sous # provenance; "user", "sous" for an entry sous provides itself, or the
|
|
250
|
+
# ref of the recipe that pulled it in; one you add records 'addedAt' too
|
|
679
251
|
subscriptions:
|
|
680
252
|
workflow/task-files:
|
|
681
|
-
range: ^1.2.0
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
#
|
|
687
|
-
#
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
watchPollSeconds: 300 # five minutes
|
|
253
|
+
range: "^1.2.0" # defaults to "*"; prerelease, enabled, alwaysPull, addedAt
|
|
254
|
+
prerelease: false # and addedBy work as they do above
|
|
255
|
+
store: # every value is a default you can change
|
|
256
|
+
maxBytes: 1073741824 # one gigabyte; past it, 'sous repo gc' evicts unpinned entries
|
|
257
|
+
freshnessSeconds: 300 # how long a fetched index stays fresh
|
|
258
|
+
watchPollSeconds: 300 # how often watch mode polls upstream
|
|
259
|
+
recipeOutputs: # 'memories' and 'prompts' take the same list-of-paths shape
|
|
260
|
+
skills: ["${projectRoot}/.claude/skills", "${projectRoot}/.codex/skills"]
|
|
261
|
+
varMappings:
|
|
262
|
+
QA_SERVICE_TOKEN: workflow/qa-variables/qaServiceToken
|
|
692
263
|
```
|
|
693
264
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
- the repository `sous-recipes`, pointing at `https://github.com/sous-io/sous-recipes`, and
|
|
704
|
-
- a `core` subscription, whose range is exactly the version of sous you are running.
|
|
705
|
-
|
|
706
|
-
The `core` namespace holds the skills that teach an agent what sous is and how it works, and a
|
|
707
|
-
copy of it ships inside the sous package, so a brand new project builds with those skills even
|
|
708
|
-
with no network. Trust is not a question here: sous itself ships the recipe and pins the
|
|
709
|
-
version to its own.
|
|
265
|
+
| Key | Keyed by | Notes |
|
|
266
|
+
|-----|----------|-------|
|
|
267
|
+
| `repos` | the short name refs use | Adding a repository IS trusting it, and removing the entry withdraws that trust |
|
|
268
|
+
| `subscriptions` | a ref key | A bare namespace (every recipe in it, including ones published later) or `namespace/recipe`. No repo qualifier and no range in the key |
|
|
269
|
+
| `enabled: false` | either of the two above | The opt-out for the entries sous provides itself, the `sous-recipes` repository and the `core` subscription |
|
|
270
|
+
| `store` | fixed fields | Plain numbers, all optional; the values sous ships are defaults, not assumptions |
|
|
271
|
+
| `recipeOutputs` | content kind | Destination directories per kind; see [recipeOutputs: where the files land](#recipeoutputs-where-the-files-land) |
|
|
272
|
+
| `varMappings` | environment variable name | Binds one name to one recipe variable, written `namespace/recipe/variableName` with an optional `repo:` qualifier |
|
|
710
273
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
274
|
+
A repository's `url` may be a URL or an absolute path to one on this machine, read by the `local` provider
|
|
275
|
+
with identical trust. A hand-written entry is laid over sous's default field by field, so an entry holding
|
|
276
|
+
`{ enabled: false }` is a complete opt-out, and a written `url` repoints the repository while the rest of
|
|
277
|
+
the default stands.
|
|
714
278
|
|
|
715
|
-
###
|
|
279
|
+
### recipeOutputs: where the files land
|
|
716
280
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
is
|
|
720
|
-
opt-out is legible to whoever reads it next.
|
|
721
|
-
|
|
722
|
-
The two entries above are what it is mostly for:
|
|
723
|
-
|
|
724
|
-
```yaml
|
|
725
|
-
# Keep the repository, but do not install the core skills.
|
|
726
|
-
subscriptions:
|
|
727
|
-
core:
|
|
728
|
-
enabled: false
|
|
729
|
-
|
|
730
|
-
# Or drop the repository entirely, which withdraws the core subscription with it.
|
|
731
|
-
repos:
|
|
732
|
-
sous-recipes:
|
|
733
|
-
enabled: false
|
|
734
|
-
```
|
|
735
|
-
|
|
736
|
-
Those two lines are complete entries on their own. Sous merges your fields over its own field by
|
|
737
|
-
field, so `{ enabled: false }` inherits the URL and provider underneath it; writing a `url`
|
|
738
|
-
repoints the repository and changes nothing else.
|
|
739
|
-
|
|
740
|
-
`alwaysPull` installs a newer in-range version whenever one exists rather than holding the
|
|
741
|
-
locked one. It never widens the range a subscription or a dependency declared, and the lockfile
|
|
742
|
-
is still regenerated continuously so it records what the last build actually used. A freshness
|
|
743
|
-
check that fails never breaks a build; the last good answer stands.
|
|
281
|
+
Where each content kind lands, `${var}` substituted as in any config path. Only `skills` has a default,
|
|
282
|
+
`<project root>/.claude/skills`; sous cannot guess where memories or prompts go, so a kind with no
|
|
283
|
+
destination is skipped and one warning names this key. A recipe's `config` contents become layers, not files.
|
|
744
284
|
|
|
745
285
|
## Managed config layers
|
|
746
286
|
|
|
747
|
-
|
|
748
|
-
layers
|
|
749
|
-
|
|
750
|
-
| File | Holds | Written by |
|
|
751
|
-
|------|-------|------------|
|
|
752
|
-
| `conf.d/500-repos.jsonc` | the `repos:` map | `sous repo add` |
|
|
753
|
-
| `conf.d/510-subscriptions.jsonc` | the `subscriptions:` map | `sous subscription add`, `sous subscription remove` |
|
|
754
|
-
| `conf.d/520-var-mappings.jsonc` | the `varMappings:` map | `sous vars ask` |
|
|
755
|
-
|
|
756
|
-
All three are ordinary config layers: they load in filename order after your primary config and
|
|
757
|
-
merge into it, so a repository you hand-write in your own config and one sous added are the same
|
|
758
|
-
thing by the time anything reads them. Sous never edits your primary config, and never edits a
|
|
759
|
-
layer outside the `500` through `599` band.
|
|
287
|
+
Three files in a project's `conf.d/` are written by sous rather than by a person, in the machine-written
|
|
288
|
+
`500` to `599` band described under [Layers and merging](config-layers.md#the-managed-5xx-layer-band). They
|
|
289
|
+
are `.jsonc` so they can carry real comments, and each opens with a header saying what it is:
|
|
760
290
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
They are `.jsonc`, not `.json`, which is why each one can open with a header comment saying what it
|
|
767
|
-
holds:
|
|
291
|
+
| Layer | Holds | Written by |
|
|
292
|
+
|-------|-------|-----------|
|
|
293
|
+
| `500-repos.jsonc` | `repos` | `sous repo add`, `sous repo remove` |
|
|
294
|
+
| `510-subscriptions.jsonc` | `subscriptions` | `sous subscription add`, `sous subscription remove` |
|
|
295
|
+
| `520-var-mappings.jsonc` | `varMappings` | `sous vars ask` |
|
|
768
296
|
|
|
769
297
|
```jsonc
|
|
770
298
|
// This file is managed by sous. Sous edits these files by key; you may edit
|
|
771
299
|
// them too, and your comments, key order and formatting are kept.
|
|
772
300
|
//
|
|
773
|
-
// It records the repositories this project trusts. The 'sous repo add'
|
|
774
|
-
//
|
|
301
|
+
// It records the repositories this project trusts. The 'sous repo add' and
|
|
302
|
+
// 'sous repo remove' commands write the entries under 'repos'.
|
|
775
303
|
//
|
|
776
304
|
// It is JSON with comments (.jsonc): line comments, block comments and trailing
|
|
777
305
|
// commas are all allowed here.
|
|
778
|
-
{
|
|
779
|
-
"repos": {
|
|
780
|
-
"sous-recipes": {
|
|
781
|
-
"url": "https://github.com/sous-io/sous-recipes",
|
|
782
|
-
"addedAt": "2026-09-09T14:03:11.482Z",
|
|
783
|
-
// ours; the whole team reads from it
|
|
784
|
-
"addedBy": "user"
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
Sous reads `.jsonc` anywhere it reads `.json`: a primary `sous.config.jsonc`, any `conf.d/` layer, a
|
|
791
|
-
repo or recipe manifest, and a config layer a recipe contributes. A layer still named
|
|
792
|
-
`500-repos.json` from an older sous is read as a fallback, and the next write moves it to `.jsonc`
|
|
793
|
-
and removes the old file. Only one of the two names may exist at a time; two layers whose names
|
|
794
|
-
differ only by extension are a hard error.
|
|
795
|
-
|
|
796
|
-
Where a comment is impossible because the format really is strict JSON, the convention is a `//`
|
|
797
|
-
key, which sous ignores wherever it appears. Nothing sous writes uses one today.
|
|
798
|
-
|
|
799
|
-
The machine-written files that are NOT config layers stay strict JSON, because they carry no prose
|
|
800
|
-
and other tooling parses them: the lockfile, the repo index, the links map and the marker beside a
|
|
801
|
-
store entry.
|
|
802
|
-
|
|
803
|
-
## Consuming recipes
|
|
804
|
-
|
|
805
|
-
Subscribing pins a recipe; building is what turns it into files in your project. A recipe's
|
|
806
|
-
`contents` block says what it contributes and of what kind, and each kind lands somewhere your
|
|
807
|
-
config decides.
|
|
808
|
-
|
|
809
|
-
### `recipeOutputs`: where the files land
|
|
810
|
-
|
|
811
|
-
```yaml
|
|
812
|
-
# Where the files subscribed recipes contribute are written, per content kind.
|
|
813
|
-
# Every path is ${var} substituted like any other config path, and a kind may
|
|
814
|
-
# name several destinations so one recipe feeds more than one agent directory.
|
|
815
|
-
recipeOutputs:
|
|
816
|
-
skills:
|
|
817
|
-
- ${projectRoot}/.claude/skills
|
|
818
|
-
- ${projectRoot}/.codex/skills
|
|
819
|
-
memories:
|
|
820
|
-
- ${projectRoot}/.claude/memories
|
|
821
|
-
prompts:
|
|
822
|
-
- ${projectRoot}/prompts/recipes
|
|
823
|
-
```
|
|
824
|
-
|
|
825
|
-
Only `skills` has a default: `<project root>/.claude/skills`, the project root being the parent
|
|
826
|
-
of your `.sous/` directory. That is where every agent looks, so it is worth defaulting. Nothing
|
|
827
|
-
else is: a kind with no destination is skipped, and sous says so once, naming this key. It will
|
|
828
|
-
not guess where you want your memories or your prompts.
|
|
829
|
-
|
|
830
|
-
Files are written the same way an `entryGlob` target of your own writes them. The static part of
|
|
831
|
-
each include pattern is the base the output tree mirrors, so a recipe publishing
|
|
832
|
-
`skills/task-files/SKILL.md` under `include: ["skills/**/*.md"]` writes
|
|
833
|
-
`<destination>/task-files/SKILL.md`. The [`.tpl.` convention](configuration.md) applies
|
|
834
|
-
unchanged: a `.tpl.md` file is rendered and loses the `.tpl.` from its name, and everything else
|
|
835
|
-
is copied verbatim.
|
|
836
|
-
|
|
837
|
-
?> Only recipes you are subscribed to contribute files. A recipe pulled in through `depends` is
|
|
838
|
-
fetched, pinned and addressable from the recipe that declared it, and its files never enter your
|
|
839
|
-
output. That is the whole difference between the two dependency kinds.
|
|
840
|
-
|
|
841
|
-
Recipe outputs are tracked like every other file sous writes, so `sous prune` removes what an
|
|
842
|
-
unsubscribed recipe used to write, and `sous clear` removes all of it. Neither ever reaches into
|
|
843
|
-
a linked checkout or the machine-wide store; both hold work that is not a project's to delete.
|
|
844
|
-
|
|
845
|
-
### `config` contents: recipes that configure
|
|
846
|
-
|
|
847
|
-
A recipe's `config` contents are not written anywhere. They are config layers, and they load
|
|
848
|
-
after your primary config and before your own `conf.d/` layers, so a recipe can supply defaults
|
|
849
|
-
and your project always wins over them.
|
|
850
|
-
|
|
851
|
-
A recipe's config layer is JSON or YAML only (`.json`, `.yaml` or `.yml`). Sous must be able to
|
|
852
|
-
read everything a repository publishes without running any of it, so an executable layer from a
|
|
853
|
-
recipe is refused with a warning rather than loaded, exactly as manifests are.
|
|
854
|
-
|
|
855
|
-
A recipe's config layer may set only these top-level keys:
|
|
856
|
-
|
|
857
|
-
| Key | What a recipe uses it for |
|
|
858
|
-
|-----|---------------------------|
|
|
859
|
-
| `_vars` | Default values for the variables its templates read |
|
|
860
|
-
| `_aliases` | Include aliases pointing at the files it ships |
|
|
861
|
-
| `compilation` | Targets that compile what it ships |
|
|
862
|
-
| `runtimeContext` | Runtime context for the templates it ships |
|
|
863
|
-
| `recipeOutputs` | Where the files it contributes are written |
|
|
864
|
-
| `store` | Knobs for the machine-wide store |
|
|
865
|
-
| `varMappings` | Bindings from an environment variable name to one of its variables |
|
|
866
|
-
|
|
867
|
-
Every other key is removed before the layer is merged, and sous prints a warning naming the
|
|
868
|
-
recipe and the key it removed. In particular a recipe may not set `repos`, `subscriptions`,
|
|
869
|
-
`tools`, `_env`, `version`, `name` or `$schema`, and it may not set a key sous does not
|
|
870
|
-
recognise. Subscribing to a recipe is not a decision to let it choose which repositories this
|
|
871
|
-
project trusts, what else it subscribes to, or which programs `sous launch` runs; those stay
|
|
872
|
-
yours. Sous reads the layer itself and applies this filter before the config kernel merges
|
|
873
|
-
anything, so the kernel never opens a recipe's layer file at all.
|
|
874
|
-
|
|
875
|
-
### The `local` provider: repositories on this machine
|
|
876
|
-
|
|
877
|
-
A repository does not have to be hosted. Name one by a path, relative or absolute, or by the
|
|
878
|
-
same path in `file:///...` form, and sous reads it through the built-in `local` provider:
|
|
879
|
-
|
|
880
|
-
```bash
|
|
881
|
-
sous repo add /home/me/Projects/my-recipes --name my-recipes
|
|
882
|
-
sous repo add ../my-recipes --name my-recipes
|
|
306
|
+
{ "repos": { "qa-recipes": { "url": "/home/me/Projects/qa", "addedBy": "user" } } }
|
|
883
307
|
```
|
|
884
308
|
|
|
885
|
-
A
|
|
886
|
-
a
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
publishing it, or running a whole workflow with no network at all. The index is read from the
|
|
890
|
-
working tree when the file is there, so an index you are still writing is picked up without a
|
|
891
|
-
commit, and from the committed copy otherwise. A recipe's files come from the version's tag in
|
|
892
|
-
the local git repository; a directory that is not a git repository has no versions to honour, so
|
|
893
|
-
its working tree is copied instead.
|
|
894
|
-
|
|
895
|
-
!> Trust semantics are identical to a hosted repository. A local path is added, and therefore
|
|
896
|
-
trusted, through the same ceremony, because the recipes in it still run on this machine. "It is
|
|
897
|
-
already on my disk" is not a reason to skip the question.
|
|
898
|
-
|
|
899
|
-
For editing a repository you are already subscribed to, reach for `sous repo link` instead: it
|
|
900
|
-
redirects one repository's resolution at a working copy without changing what your project
|
|
901
|
-
subscribes to.
|
|
902
|
-
|
|
903
|
-
## Variables and answers
|
|
904
|
-
|
|
905
|
-
A variable **definition** is a published specification; an **answer** is the stored value. A
|
|
906
|
-
question is asked only when a subscribed recipe needs a variable and nothing in scope answers
|
|
907
|
-
it, or when the answer in scope no longer fits the definition.
|
|
908
|
-
|
|
909
|
-
### Where answers live
|
|
910
|
-
|
|
911
|
-
Answers are stored in the project's own env files, and sous edits them the way a careful person
|
|
912
|
-
would: exactly one value line is rewritten or appended, and comments, blank lines, ordering and
|
|
913
|
-
quoting all survive. A newly added entry gets a short generated header comment above it saying
|
|
914
|
-
where the value came from. Comments are output only; sous never reads one back.
|
|
915
|
-
|
|
916
|
-
| File | Committed | Holds |
|
|
917
|
-
|------|-----------|-------|
|
|
918
|
-
| `.sous/.env` | yes | Shared answers (`scope: shared`), the team's defaults |
|
|
919
|
-
| `.sous/.env.local` | no, gitignored | Machine-specific answers (`scope: local`) and every secret |
|
|
920
|
-
|
|
921
|
-
### The resolution ladder
|
|
922
|
-
|
|
923
|
-
For each variable sous generates a list of environment variable names and tries them in order,
|
|
924
|
-
most specific first. Within a rung, the real shell environment wins, then `.sous/.env.local`,
|
|
925
|
-
then `.sous/.env`.
|
|
926
|
-
|
|
927
|
-
| Rung | Name | Example |
|
|
928
|
-
|------|------|---------|
|
|
929
|
-
| 1. mapping record | whatever the record names | `TEAM_API_URL` |
|
|
930
|
-
| 2. recipe scope | `SOUS_VAR_<NAMESPACE>_<RECIPE>_<VARIABLE>` | `SOUS_VAR_MISC_STUFF_API_URL` |
|
|
931
|
-
| 3. namespace scope | `SOUS_VAR_<NAMESPACE>_<VARIABLE>` | `SOUS_VAR_MISC_API_URL` |
|
|
932
|
-
| 4. shared scope | `SOUS_VAR_<VARIABLE>` | `SOUS_VAR_API_URL` |
|
|
933
|
-
| 5. declared name | the definition's own `env` field | `GITHUB_TOKEN` |
|
|
934
|
-
|
|
935
|
-
!> Candidate names are only ever GENERATED and looked up, never parsed back into scopes. The
|
|
936
|
-
underscore is both the delimiter and a legal identifier character, so no parse of a name would
|
|
937
|
-
be trustworthy. When names collide, a mapping record settles it.
|
|
938
|
-
|
|
939
|
-
### Mapping records
|
|
940
|
-
|
|
941
|
-
A mapping record binds one environment variable, of any name, to one fully qualified variable.
|
|
942
|
-
It is the top rung of the ladder and the universal conflict resolver: two recipes wanting the
|
|
943
|
-
same name, or a name already meaning something else in your environment.
|
|
944
|
-
|
|
945
|
-
```jsonc
|
|
946
|
-
// This file is managed by sous. Sous edits these files by key; you may edit
|
|
947
|
-
// them too, and your comments, key order and formatting are kept.
|
|
948
|
-
{
|
|
949
|
-
"varMappings": {
|
|
950
|
-
"TEAM_API_URL": "sous-recipes:misc/stuff/apiUrl"
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
```
|
|
954
|
-
|
|
955
|
-
A target is written `namespace/recipe/variableName`, optionally qualified as
|
|
956
|
-
`repo:namespace/recipe/variableName`. Records live under the top-level `varMappings` config key;
|
|
957
|
-
sous writes the ones it creates into the machine-managed `conf.d/520-var-mappings.jsonc` layer,
|
|
958
|
-
editing one record at a time so each name has exactly one, and you may hand-write `varMappings` in
|
|
959
|
-
the primary config too.
|
|
960
|
-
|
|
961
|
-
### The commands
|
|
962
|
-
|
|
963
|
-
| Command | What it does |
|
|
964
|
-
|---------|--------------|
|
|
965
|
-
| `sous vars list` | Lists every variable in play: its recipe, the environment variable that answered it, the value (hidden for a secret) and the source |
|
|
966
|
-
| `sous vars show <name>` | Shows one variable in full, including every environment variable on the ladder and which rung answered |
|
|
967
|
-
| `sous vars ask [name]` | Answers what is unanswered, or one named variable; `--all` asks everything again |
|
|
968
|
-
| `sous vars ask --file <path>` | Asks the definitions in a standalone file holding the same `variables:` array a recipe manifest carries |
|
|
969
|
-
|
|
970
|
-
`sous vars list`, `sous vars show` and `sous vars ask` all accept `--file`, and `sous vars ask`
|
|
971
|
-
accepts `--dry-run`.
|
|
972
|
-
|
|
973
|
-
Answers already in scope are listed visibly and never re-asked. Without a terminal, an
|
|
974
|
-
unanswered required variable fails the run and names the exact environment variables that would
|
|
975
|
-
satisfy it, most specific first, which is what a continuous integration log needs.
|
|
976
|
-
|
|
977
|
-
## Releasing and contributing
|
|
978
|
-
|
|
979
|
-
Two commands work inside a recipe repository rather than inside a project, so neither one looks
|
|
980
|
-
for a `.sous/` directory: `sous repo release` publishes, and `sous repo submit` proposes a change
|
|
981
|
-
to a repository someone else maintains. Both refuse to do anything until the repository describes
|
|
982
|
-
itself consistently.
|
|
983
|
-
|
|
984
|
-
### What is checked
|
|
985
|
-
|
|
986
|
-
Every run of either command checks the same things, and reports all of them at once rather than
|
|
987
|
-
stopping at the first:
|
|
988
|
-
|
|
989
|
-
- Every folder listed under `recipes` in `sous.repo.yaml` exists and holds exactly one recipe
|
|
990
|
-
manifest.
|
|
991
|
-
- Every recipe belongs to a namespace the repository manifest declares, and no two recipes share
|
|
992
|
-
a `namespace/name` key.
|
|
993
|
-
- Every `depends` and `subscribes` ref parses.
|
|
994
|
-
- No two variable definitions of DIFFERENT names claim the same environment variable. Two
|
|
995
|
-
definitions of the SAME name may share one, because that is exactly what the shared rung of the
|
|
996
|
-
resolution ladder is for.
|
|
997
|
-
- A tag exists for the version its recipe manifest declares, or that version is pending; when the
|
|
998
|
-
tag does exist, the manifest carried by that tag declares the same version, and the folder's
|
|
999
|
-
content still matches what the tag published.
|
|
1000
|
-
|
|
1001
|
-
Claiming a well-known name such as `PATH`, `HOME`, `GITHUB_TOKEN` or anything beginning `AWS_` or
|
|
1002
|
-
`SOUS_` is a warning rather than an error. Binding an existing token is legitimate; it just
|
|
1003
|
-
deserves saying out loud. Add `x-intentional: true` to the definition to say you meant it.
|
|
1004
|
-
|
|
1005
|
-
### Versions, tags and the index
|
|
1006
|
-
|
|
1007
|
-
Recipe metadata is the source of truth for versions. A tag shaped `namespace/recipe@1.2.3` is a
|
|
1008
|
-
convenience ref that records which commit a version was published from, and `sous.index.json` is
|
|
1009
|
-
the catalog subscribers read. The three are kept in step by three rules:
|
|
1010
|
-
|
|
1011
|
-
- **A published version never changes.** Its hash is carried forward exactly as published, and a
|
|
1012
|
-
disagreement is an error telling you to bump the version rather than republish it.
|
|
1013
|
-
- **A version is published when its tag exists.** The one moment an index records a version
|
|
1014
|
-
without a tag is the release commit itself: the index is committed and the tag is cut on that
|
|
1015
|
-
commit. Any older version missing its tag is an error.
|
|
1016
|
-
- **The tags are the backstop.** A tagged version missing from the index is rebuilt from its tag,
|
|
1017
|
-
so deleting `sous.index.json` and regenerating it restores the same catalog.
|
|
1018
|
-
|
|
1019
|
-
!> A release commits the version bumps and the index it writes, and nothing else. It refuses to
|
|
1020
|
-
run while anything else is uncommitted, because a tag names one commit and the index records
|
|
1021
|
-
what each recipe folder holds right now.
|
|
1022
|
-
|
|
1023
|
-
### `sous repo release`
|
|
1024
|
-
|
|
1025
|
-
One run plans, asks once, and then publishes. Within its scope it releases only recipes whose
|
|
1026
|
-
files changed since the tag that last published them, bumping any whose version still equals
|
|
1027
|
-
that tag.
|
|
1028
|
-
|
|
1029
|
-
| Invocation | What it does |
|
|
1030
|
-
|------------|--------------|
|
|
1031
|
-
| `sous repo release` | Plan, ask once, then bump, regenerate the index, commit and tag. |
|
|
1032
|
-
| `sous repo release --dry-run` | Print the plan and stop. |
|
|
1033
|
-
| `sous repo release --yes` | Skip the question; everything else is the same. |
|
|
1034
|
-
| `sous repo release --namespace <ns>` | Release only that namespace. Repeatable. |
|
|
1035
|
-
| `sous repo release --recipe <ns/name>` | Release only that recipe. Repeatable. |
|
|
1036
|
-
| `sous repo release --bump <level>` | `patch` (the default), `minor`, `major` or `prerelease`. |
|
|
1037
|
-
| `sous repo release --no-bump` | Raise nothing; a changed recipe nobody raised is an error. |
|
|
1038
|
-
| `sous repo release --include-unchanged` | Release everything in scope, changed or not. |
|
|
1039
|
-
| `sous repo release --tag` | Cut the tags even on a branch other than the default one. |
|
|
1040
|
-
| `sous repo release --push` | Push the commit, and the tags this run created, to `origin`. |
|
|
1041
|
-
| `sous repo release --check` | Read only: validate, and fail when the committed index is out of date. This is what a pull request runs. |
|
|
1042
|
-
| `sous repo release --ci` | The merge preset: never bump, accept the plan, never ask, fail on anything unbumped. |
|
|
1043
|
-
|
|
1044
|
-
Tags are cut dependency-first, and each version's resolved dependencies are written into the
|
|
1045
|
-
index. On a branch other than the default one a release bumps and commits but cuts no tags,
|
|
1046
|
-
because tags are cut on the default branch by the merge; `--tag` overrides that.
|
|
1047
|
-
|
|
1048
|
-
A version bump edits the manifest in place, so its comments, its field order and its layout
|
|
1049
|
-
survive. Two small normalizations happen in a YAML manifest: a folded block of prose may be
|
|
1050
|
-
re-wrapped, and the spacing before a trailing comment is collapsed to one space.
|
|
1051
|
-
|
|
1052
|
-
The workflow `sous repo init` scaffolds runs `--check` on every pull request and `--ci --push`
|
|
1053
|
-
on a merge.
|
|
1054
|
-
|
|
1055
|
-
### `sous repo submit`
|
|
1056
|
-
|
|
1057
|
-
`submit` means "propose a change for maintainers to review". It never publishes and never writes
|
|
1058
|
-
to a repository directly.
|
|
1059
|
-
|
|
1060
|
-
Sous validates first, because a proposal that fails the maintainer's own checks wastes their
|
|
1061
|
-
review, and then hands the mechanics to the provider's own command line tool, which already holds
|
|
1062
|
-
your credentials:
|
|
1063
|
-
|
|
1064
|
-
1. **Preflight.** An `origin` remote exists, sous recognizes its provider, that provider's CLI
|
|
1065
|
-
(`gh` or `glab`) is installed and signed in, and everything is committed.
|
|
1066
|
-
2. **Validation.** The repository validates, and the committed index is current.
|
|
1067
|
-
3. **Delegation.** On GitHub, sous asks whether you can push to the repository itself; if you
|
|
1068
|
-
cannot, it forks it onto your own account and proposes from there. The branch is pushed and a
|
|
1069
|
-
pull request (a merge request on GitLab) is opened. A change sitting on the default branch is
|
|
1070
|
-
moved to a branch named `sous/submit-<date>-<time>` first.
|
|
1071
|
-
|
|
1072
|
-
| Flag | What it does |
|
|
1073
|
-
|------|--------------|
|
|
1074
|
-
| `--title <text>` | The proposal's title. Defaults to your last commit's subject. |
|
|
1075
|
-
| `--body <text>` | The proposal's body. Defaults to a summary sous writes, listing the recipes and the versions the change would publish. |
|
|
1076
|
-
| `--draft` | Opens the proposal as a draft. |
|
|
1077
|
-
| `--dry-run` | Runs the whole preflight and sends nothing. |
|
|
309
|
+
A write rewrites only the bytes of the entry that changes, staging the result and renaming it over the file,
|
|
310
|
+
so an interrupted write leaves the previous layer rather than a truncated one. A layer still carrying its
|
|
311
|
+
old `.json` name is read as a fallback and migrates on the next write: the `.jsonc` file is written and the
|
|
312
|
+
`.json` one removed, so a project never ends up with both, which would be a hard config error.
|
|
1078
313
|
|
|
1079
|
-
|
|
1080
|
-
branch with no proposal behind it is a normal outcome of a network failure, and you are told
|
|
1081
|
-
about it rather than left guessing.
|
|
314
|
+
## Where to go next
|
|
1082
315
|
|
|
1083
|
-
|
|
1084
|
-
|
|
316
|
+
- [Repositories](repositories.md): the model, trust, and the lockfile
|
|
317
|
+
- [Consuming recipes](repositories-consuming.md): adding, subscribing and building
|
|
318
|
+
- [Authoring a repository](repositories-authoring.md): writing and releasing recipes
|
|
319
|
+
- [Providers](repositories-providers.md): how a URL becomes a repository identity
|
|
320
|
+
- [Troubleshooting repositories](repositories-troubleshooting.md): what each error means
|