Skip to content

Commit b36360b

Browse files
committed
feat: Added syncthing support
1 parent 722b89f commit b36360b

7 files changed

Lines changed: 1072 additions & 2 deletions

File tree

scripts/runbook.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { query } from "@anthropic-ai/claude-agent-sdk";
22

3-
const toolName = 'ollama';
4-
const toolHomepage = 'https://github.com/ollama/ollama'
3+
const toolName = 'syncthing';
4+
const toolHomepage = 'https://docs.syncthing.net/'
5+
const description = 'Make sure that the resources created allow the usage and configuration of syncthing directly. Syncthing has a CLI so this should be possible.'
56

67
const researchResults: string[] = [];
78

89
for await (const message of query({
910
prompt:
1011
`Research and design a Codify resource for ${toolName} (the homepage is: ${toolHomepage})
12+
13+
${description}
1114
1215
The research should include:
1316
** The installation method **
@@ -64,6 +67,8 @@ it can be easily understood by Claude.
6467
for await (const message of query({
6568
prompt: `Use the research results to design a Codify resource for ${toolName} (the homepage is: ${toolHomepage}).
6669
70+
${description}
71+
6772
Guidelines:
6873
- Follow the other tools in the project under @src/resources/** as a guideline
6974
- Prefer to use Zod over JSON Schema

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ import { AliasResource } from './resources/shell/alias/alias-resource.js';
3737
import { AliasesResource } from './resources/shell/aliases/aliases-resource.js';
3838
import { PathResource } from './resources/shell/path/path-resource.js';
3939
import { SnapResource } from './resources/snap/snap.js';
40+
import { SyncthingResource } from './resources/syncthing/syncthing.js';
41+
import { SyncthingDeviceResource } from './resources/syncthing/syncthing-device.js';
42+
import { SyncthingFolderResource } from './resources/syncthing/syncthing-folder.js';
4043
import { SshAddResource } from './resources/ssh/ssh-add.js';
4144
import { SshConfigFileResource } from './resources/ssh/ssh-config.js';
4245
import { SshKeyResource } from './resources/ssh/ssh-key.js';
@@ -95,6 +98,9 @@ runPlugin(Plugin.create(
9598
new TartResource(),
9699
new TartVmResource(),
97100
new OllamaResource(),
101+
new SyncthingResource(),
102+
new SyncthingDeviceResource(),
103+
new SyncthingFolderResource(),
98104
new RbenvResource(),
99105
])
100106
)
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import {
2+
CreatePlan,
3+
DestroyPlan,
4+
ModifyPlan,
5+
ParameterChange,
6+
Resource,
7+
ResourceSettings,
8+
SpawnStatus,
9+
getPty,
10+
z,
11+
} from '@codifycli/plugin-core';
12+
import { OS } from '@codifycli/schemas';
13+
14+
import { isDaemonRunning } from './syncthing-utils.js';
15+
16+
const schema = z
17+
.object({
18+
deviceId: z
19+
.string()
20+
.describe('The Syncthing device ID (e.g. XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX)'),
21+
name: z
22+
.string()
23+
.optional()
24+
.describe('Human-readable label for this device'),
25+
addresses: z
26+
.array(z.string())
27+
.optional()
28+
.describe('Connection addresses; use ["dynamic"] for automatic discovery (default: ["dynamic"])'),
29+
autoAcceptFolders: z
30+
.boolean()
31+
.optional()
32+
.describe('Automatically accept folder shares offered by this device (default: false)'),
33+
paused: z
34+
.boolean()
35+
.optional()
36+
.describe('Pause syncing with this device without removing it (default: false)'),
37+
compression: z
38+
.enum(['always', 'metadata', 'never'])
39+
.optional()
40+
.describe('Data compression mode for transfers to this device (default: metadata)'),
41+
maxSendKbps: z
42+
.number()
43+
.int()
44+
.min(0)
45+
.optional()
46+
.describe('Per-device outgoing rate limit in KiB/s; 0 = unlimited'),
47+
maxRecvKbps: z
48+
.number()
49+
.int()
50+
.min(0)
51+
.optional()
52+
.describe('Per-device incoming rate limit in KiB/s; 0 = unlimited'),
53+
})
54+
.meta({ $comment: 'https://codifycli.com/docs/resources/syncthing/syncthing-device' })
55+
.describe('A remote Syncthing peer device');
56+
57+
export type SyncthingDeviceConfig = z.infer<typeof schema>;
58+
59+
/** Raw JSON shape returned by `syncthing cli config devices <id>` */
60+
interface RawDevice {
61+
deviceID: string;
62+
name: string;
63+
addresses: string[];
64+
compression: string;
65+
autoAcceptFolders: boolean;
66+
paused: boolean;
67+
maxSendKbps: number;
68+
maxRecvKbps: number;
69+
}
70+
71+
export class SyncthingDeviceResource extends Resource<SyncthingDeviceConfig> {
72+
getSettings(): ResourceSettings<SyncthingDeviceConfig> {
73+
return {
74+
id: 'syncthing-device',
75+
operatingSystems: [OS.Darwin, OS.Linux],
76+
dependencies: ['syncthing'],
77+
schema,
78+
allowMultiple: {
79+
identifyingParameters: ['deviceId'],
80+
},
81+
parameterSettings: {
82+
name: { type: 'string', canModify: true },
83+
addresses: { type: 'array', canModify: true },
84+
autoAcceptFolders: { type: 'boolean', canModify: true },
85+
paused: { type: 'boolean', canModify: true },
86+
compression: { type: 'string', canModify: true },
87+
maxSendKbps: { type: 'number', canModify: true },
88+
maxRecvKbps: { type: 'number', canModify: true },
89+
},
90+
};
91+
}
92+
93+
async refresh(
94+
params: Partial<SyncthingDeviceConfig>
95+
): Promise<Partial<SyncthingDeviceConfig> | null> {
96+
if (!(await isDaemonRunning())) {
97+
return null;
98+
}
99+
100+
const raw = await this.fetchDevice(params.deviceId!);
101+
if (!raw) {
102+
return null;
103+
}
104+
105+
return deviceFromRaw(raw);
106+
}
107+
108+
async create(plan: CreatePlan<SyncthingDeviceConfig>): Promise<void> {
109+
const $ = getPty();
110+
const { deviceId, name, addresses, autoAcceptFolders, paused, compression, maxSendKbps, maxRecvKbps } =
111+
plan.desiredConfig;
112+
113+
const args = buildDeviceAddArgs({ deviceId, name, addresses, autoAcceptFolders, paused, compression, maxSendKbps, maxRecvKbps });
114+
await $.spawn(`syncthing cli config devices add ${args}`, { interactive: true });
115+
}
116+
117+
async modify(
118+
pc: ParameterChange<SyncthingDeviceConfig>,
119+
plan: ModifyPlan<SyncthingDeviceConfig>
120+
): Promise<void> {
121+
const $ = getPty();
122+
const { deviceId } = plan.desiredConfig;
123+
const value = plan.desiredConfig[pc.name as keyof SyncthingDeviceConfig];
124+
125+
const cliPath = deviceOptionCliPath(pc.name as keyof SyncthingDeviceConfig);
126+
if (cliPath && value !== undefined) {
127+
await $.spawn(`syncthing cli config devices ${deviceId} ${cliPath} set ${value}`, {
128+
interactive: true,
129+
});
130+
}
131+
}
132+
133+
async destroy(plan: DestroyPlan<SyncthingDeviceConfig>): Promise<void> {
134+
const $ = getPty();
135+
await $.spawn(`syncthing cli config devices ${plan.currentConfig.deviceId} delete`, {
136+
interactive: true,
137+
});
138+
}
139+
140+
// ── Helpers ────────────────────────────────────────────────────────────────
141+
142+
private async fetchDevice(deviceId: string): Promise<RawDevice | null> {
143+
const $ = getPty();
144+
145+
// First verify the device ID is in the configured list
146+
const { status: listStatus, data: listData } = await $.spawnSafe(
147+
'syncthing cli config devices list'
148+
);
149+
if (listStatus !== SpawnStatus.SUCCESS) {
150+
return null;
151+
}
152+
153+
let ids: string[];
154+
try {
155+
ids = JSON.parse(listData) as string[];
156+
} catch {
157+
return null;
158+
}
159+
160+
if (!ids.includes(deviceId)) {
161+
return null;
162+
}
163+
164+
// Fetch the full device configuration
165+
const { status, data } = await $.spawnSafe(`syncthing cli config devices ${deviceId}`);
166+
if (status !== SpawnStatus.SUCCESS) {
167+
return null;
168+
}
169+
170+
try {
171+
return JSON.parse(data) as RawDevice;
172+
} catch {
173+
return null;
174+
}
175+
}
176+
}
177+
178+
// ── Pure helpers ─────────────────────────────────────────────────────────────
179+
180+
function deviceFromRaw(raw: RawDevice): Partial<SyncthingDeviceConfig> {
181+
return {
182+
deviceId: raw.deviceID,
183+
name: raw.name || undefined,
184+
addresses: raw.addresses,
185+
compression: raw.compression as SyncthingDeviceConfig['compression'],
186+
autoAcceptFolders: raw.autoAcceptFolders,
187+
paused: raw.paused,
188+
maxSendKbps: raw.maxSendKbps,
189+
maxRecvKbps: raw.maxRecvKbps,
190+
};
191+
}
192+
193+
function deviceOptionCliPath(key: keyof SyncthingDeviceConfig): string | undefined {
194+
const map: Partial<Record<keyof SyncthingDeviceConfig, string>> = {
195+
name: 'name',
196+
autoAcceptFolders: 'autoAcceptFolders',
197+
paused: 'paused',
198+
compression: 'compression',
199+
maxSendKbps: 'maxSendKbps',
200+
maxRecvKbps: 'maxRecvKbps',
201+
};
202+
return map[key];
203+
}
204+
205+
function buildDeviceAddArgs(config: Partial<SyncthingDeviceConfig>): string {
206+
const parts: string[] = [];
207+
208+
if (config.deviceId) parts.push(`--device-id ${config.deviceId}`);
209+
if (config.name) parts.push(`--name "${config.name}"`);
210+
if (config.addresses?.length) parts.push(`--addresses ${config.addresses.join(',')}`);
211+
if (config.autoAcceptFolders !== undefined)
212+
parts.push(`--auto-accept-folders=${config.autoAcceptFolders}`);
213+
if (config.paused !== undefined) parts.push(`--paused=${config.paused}`);
214+
if (config.compression) parts.push(`--compression ${config.compression}`);
215+
if (config.maxSendKbps !== undefined) parts.push(`--max-send-kbps ${config.maxSendKbps}`);
216+
if (config.maxRecvKbps !== undefined) parts.push(`--max-recv-kbps ${config.maxRecvKbps}`);
217+
218+
return parts.join(' ');
219+
}

0 commit comments

Comments
 (0)