Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 21 additions & 17 deletions .claude/skills/swamp-vault/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,23 @@ Correct flow: `swamp vault create <type> <name> --json` → edit config if neede

## Quick Reference

| Task | Command |
| ------------------ | ------------------------------------------------------ |
| List vault types | `swamp vault type search --json` |
| Create a vault | `swamp vault create <type> <name> --json` |
| Search vaults | `swamp vault search [query] --json` |
| Get vault details | `swamp vault get <name_or_id> --json` |
| Edit vault config | `swamp vault edit <name_or_id>` |
| Store a secret | `swamp vault put <vault> KEY=VALUE --json` |
| Store from stdin | `echo "val" \| swamp vault put <vault> KEY --json` |
| Store interactive | `swamp vault put <vault> KEY` (prompts for value) |
| Read a secret | `swamp vault read-secret <vault> <key> --force --json` |
| List secret keys | `swamp vault list-keys <vault> --json` |
| Annotate a secret | `swamp vault annotate <vault> <key> --url <u>` |
| Inspect annotation | `swamp vault inspect <vault> <key> --json` |
| Clear annotation | `swamp vault annotate <vault> <key> --clear` |
| Migrate backend | `swamp vault migrate <vault> --to-type <type>` |
| Task | Command |
| ------------------ | ------------------------------------------------------- |
| List vault types | `swamp vault type search --json` |
| Create a vault | `swamp vault create <type> <name> --json` |
| Search vaults | `swamp vault search [query] --json` |
| Get vault details | `swamp vault get <name_or_id> --json` |
| Edit vault config | `swamp vault edit <name_or_id>` |
| Store a secret | `swamp vault put <vault> KEY=VALUE --json` |
| Store from stdin | `echo "val" \| swamp vault put <vault> KEY --json` |
| Store interactive | `swamp vault put <vault> KEY` (prompts for value) |
| Read a secret | `swamp vault read-secret <vault> <key> --force --json` |
| List secret keys | `swamp vault list-keys <vault> --json` |
| Annotate a secret | `swamp vault annotate <vault> <key> --url <u>` |
| Remove a label | `swamp vault annotate <vault> <key> --remove-label <k>` |
| Inspect annotation | `swamp vault inspect <vault> <key> --json` |
| Clear annotation | `swamp vault annotate <vault> <key> --clear` |
| Migrate backend | `swamp vault migrate <vault> --to-type <type>` |

## Repository Structure

Expand Down Expand Up @@ -223,12 +224,15 @@ updated, existing fields are preserved.
# Add a URL and notes
swamp vault annotate my-vault API_KEY \
--url https://console.aws.com/iam \
--note "Production API key for service X"
--notes "Production API key for service X"

# Add labels
swamp vault annotate my-vault API_KEY \
--label env=prod --label team=infra

# Remove a single label
swamp vault annotate my-vault API_KEY --remove-label team

# Clear all annotations
swamp vault annotate my-vault API_KEY --clear
```
Expand Down
7 changes: 5 additions & 2 deletions design/vaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,16 @@ with secret keys that might end in `.meta` or similar suffixes.
### Annotation CLI

```
swamp vault annotate <vault> <key> --url <u> --note <text> --label <k=v>
swamp vault annotate <vault> <key> --url <u> --notes <text> --label <k=v>
swamp vault annotate <vault> <key> --remove-label <key>
swamp vault inspect <vault> <key>
swamp vault annotate <vault> <key> --clear
```

Annotations use merge semantics: only the fields specified in flags are updated,
existing fields are preserved. `--clear` removes all annotations.
existing fields are preserved. `--remove-label` removes a single label by key
(repeatable). `--clear` removes all annotations and cannot be combined with
other annotation flags.

## Expression Syntax

Expand Down
30 changes: 24 additions & 6 deletions src/cli/commands/vault_annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ existing fields are preserved. Use --clear to remove all annotations.`,
.arguments("<vault_name:string> <key:string>")
.example(
"Add a URL and notes",
'swamp vault annotate my-vault API_KEY --url https://console.aws.com/iam --note "Production API key"',
'swamp vault annotate my-vault API_KEY --url https://console.aws.com/iam --notes "Production API key"',
)
.example(
"Add labels",
Expand All @@ -87,13 +87,18 @@ existing fields are preserved. Use --clear to remove all annotations.`,
"Repository directory (env: SWAMP_REPO_DIR)",
)
.option("--url <url:string>", "URL associated with this secret")
.option("--note <note:string>", "Free-text notes about this secret")
.option("--notes <notes:string>", "Free-text notes about this secret")
.option(
"--label <label:string>",
"Key=value label (repeatable)",
{ collect: true },
)
.option("--clear", "Remove all annotations from this secret")
.option(
"--remove-label <key:string>",
"Remove a label by key (repeatable)",
{ collect: true },
)
.action(async function (
options: AnyOptions,
vaultName: string,
Expand All @@ -112,13 +117,25 @@ existing fields are preserved. Use --clear to remove all annotations.`,

const clear = options.clear === true;
const labels = parseLabels(options.label);
const notes: string | undefined = options.notes;
const removeLabels: string[] | undefined = options.removeLabel;

if (
clear &&
(options.url !== undefined || notes !== undefined ||
labels !== undefined || removeLabels !== undefined)
) {
throw new UserError(
"--clear cannot be combined with --url, --notes, --label, or --remove-label. Use --clear alone to remove all annotations.",
);
}

if (
!clear && options.url === undefined && options.note === undefined &&
labels === undefined
!clear && options.url === undefined && notes === undefined &&
labels === undefined && removeLabels === undefined
) {
throw new UserError(
"No annotation fields specified. Use --url, --note, --label, or --clear.",
"No annotation fields specified. Use --url, --notes, --label, --remove-label, or --clear.",
);
}

Expand All @@ -131,8 +148,9 @@ existing fields are preserved. Use --clear to remove all annotations.`,
vaultName,
key,
url: options.url,
notes: options.note,
notes,
labels,
removeLabels,
clear,
}),
renderer.handlers(),
Expand Down
13 changes: 13 additions & 0 deletions src/domain/vaults/vault_annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ export class VaultAnnotation {
);
}

removeLabels(keys: string[]): VaultAnnotation {
const newLabels = { ...this.labels };
for (const key of keys) {
delete newLabels[key];
}
return new VaultAnnotation(
this.url,
this.notes,
newLabels,
new Date(),
);
}

isEmpty(): boolean {
return this.url === undefined &&
this.notes === undefined &&
Expand Down
26 changes: 26 additions & 0 deletions src/domain/vaults/vault_annotation_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,32 @@ Deno.test("VaultAnnotation.merge: merges labels additively", () => {
});
});

Deno.test("VaultAnnotation.removeLabels: removes specified keys", () => {
const original = VaultAnnotation.create({
url: "https://example.com",
labels: { env: "prod", team: "infra", region: "us" },
});
const result = original.removeLabels(["team", "region"]);
assertEquals(result.url, "https://example.com");
assertEquals(result.labels, { env: "prod" });
});

Deno.test("VaultAnnotation.removeLabels: ignores nonexistent keys", () => {
const original = VaultAnnotation.create({
labels: { env: "prod" },
});
const result = original.removeLabels(["missing"]);
assertEquals(result.labels, { env: "prod" });
});

Deno.test("VaultAnnotation.removeLabels: removing all keys leaves empty labels", () => {
const original = VaultAnnotation.create({
labels: { env: "prod" },
});
const result = original.removeLabels(["env"]);
assertEquals(result.labels, {});
});

Deno.test("VaultAnnotation.isEmpty: true when no fields set", () => {
const a = VaultAnnotation.create({});
assertEquals(a.isEmpty(), true);
Expand Down
32 changes: 21 additions & 11 deletions src/libswamp/vaults/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { VaultAnnotation } from "../../domain/vaults/vault_annotation.ts";
import {
VaultAnnotation,
type VaultAnnotationData,
} from "../../domain/vaults/vault_annotation.ts";
import { VaultService } from "../../domain/vaults/vault_service.ts";
import { createVaultSecretAnnotated } from "../../domain/events/types.ts";
import type { EventBus } from "../../domain/events/event_bus.ts";
Expand All @@ -40,6 +43,7 @@ export interface VaultAnnotateData {
fieldsUpdated: string[];
cleared: boolean;
timestamp: string;
annotation: VaultAnnotationData | null;
}

export type VaultAnnotateEvent =
Expand All @@ -53,6 +57,7 @@ export interface VaultAnnotateInput {
url?: string;
notes?: string;
labels?: Record<string, string>;
removeLabels?: string[];
clear: boolean;
}

Expand Down Expand Up @@ -102,12 +107,8 @@ export function createVaultAnnotateDeps(
},
secretExists: async (vaultName, key) => {
const svc = await getVaultService();
try {
await svc.get(vaultName, key);
return true;
} catch {
return false;
}
const keys = await svc.list(vaultName);
return keys.includes(key);
},
supportsAnnotations: async (vaultName) => {
const svc = await getVaultService();
Expand Down Expand Up @@ -201,12 +202,13 @@ export async function* vaultAnnotate(

if (
!input.clear && input.url === undefined &&
input.notes === undefined && input.labels === undefined
input.notes === undefined && input.labels === undefined &&
input.removeLabels === undefined
) {
yield {
kind: "error",
error: validationFailed(
"No annotation fields specified. Use --url, --note, --label, or --clear.",
"No annotation fields specified. Use --url, --notes, --label, --remove-label, or --clear.",
),
};
return;
Expand All @@ -233,6 +235,7 @@ export async function* vaultAnnotate(
fieldsUpdated: [],
cleared: true,
timestamp: new Date().toISOString(),
annotation: null,
},
};
return;
Expand All @@ -241,10 +244,12 @@ export async function* vaultAnnotate(
const fieldsUpdated: string[] = [];
if (input.url !== undefined) fieldsUpdated.push("url");
if (input.notes !== undefined) fieldsUpdated.push("notes");
if (input.labels !== undefined) fieldsUpdated.push("labels");
if (input.labels !== undefined || input.removeLabels !== undefined) {
fieldsUpdated.push("labels");
}

const existing = await deps.getAnnotation(input.vaultName, input.key);
const annotation = existing
let annotation = existing
? existing.merge({
url: input.url,
notes: input.notes,
Expand All @@ -256,6 +261,10 @@ export async function* vaultAnnotate(
labels: input.labels,
});

if (input.removeLabels !== undefined && input.removeLabels.length > 0) {
annotation = annotation.removeLabels(input.removeLabels);
}

await deps.putAnnotation(input.vaultName, input.key, annotation);
ctx.logger.debug`Annotation updated for ${input.key}`;

Expand All @@ -276,6 +285,7 @@ export async function* vaultAnnotate(
fieldsUpdated,
cleared: false,
timestamp: new Date().toISOString(),
annotation: annotation.toData(),
},
};
})(),
Expand Down
Loading
Loading