never executed always true always false
1 module PureClaw.CLI.Import
2 ( -- * JSON5 preprocessing
3 stripJson5
4 -- * OpenClaw config parsing
5 , OpenClawConfig (..)
6 , OpenClawAgent (..)
7 , OpenClawSignal (..)
8 , OpenClawTelegram (..)
9 , parseOpenClawConfig
10 -- * $include resolution
11 , resolveIncludes
12 -- * Import execution
13 , importOpenClawConfig
14 , importOpenClawDir
15 , ImportResult (..)
16 , DirImportResult (..)
17 -- * CLI options
18 , ImportOptions (..)
19 , resolveImportOptions
20 -- * Utilities (exported for testing)
21 , camelToSnake
22 , mapThinkingDefault
23 , computeMaxTurns
24 ) where
25
26 import Control.Exception (IOException, try)
27 import Control.Monad ((>=>), when)
28 import Data.Aeson
29 import Data.Aeson.Key qualified as Key
30 import Data.Aeson.KeyMap qualified as KM
31 import Data.Aeson.Types (Parser, parseEither, parseMaybe)
32 import Data.ByteString.Lazy qualified as LBS
33 import Data.Char qualified as Char
34 import Data.IORef
35 import Data.Maybe (fromMaybe)
36 import Data.Text (Text)
37 import Data.Text qualified as T
38 import Data.Text.Encoding qualified as TE
39 import Data.Text.IO qualified as TIO
40 import Data.Vector qualified as V
41 import System.Directory qualified as Dir
42 import System.FilePath ((</>), takeDirectory, takeExtension)
43
44 -- ---------------------------------------------------------------------------
45 -- JSON5 preprocessor
46 -- ---------------------------------------------------------------------------
47
48 -- | Strip JSON5 features (// comments, trailing commas) to produce valid JSON.
49 -- Handles comments inside strings correctly (does not strip them).
50 -- Does NOT handle: block comments, hex literals, multiline strings,
51 -- unquoted keys, or other advanced JSON5 features.
52 stripJson5 :: Text -> Text
53 stripJson5 = T.pack . go False . T.unpack
54 where
55 go :: Bool -> String -> String
56 go _ [] = []
57 go True ('\\' : c : rest) = '\\' : c : go True rest
58 go True ('"' : rest) = '"' : go False rest
59 go True (c : rest) = c : go True rest
60 go False ('"' : rest) = '"' : go True rest
61 go False ('/' : '/' : rest) = go False (dropWhile (/= '\n') rest)
62 go False (',' : rest)
63 | Just rest' <- skipTrailingComma rest = go False rest'
64 go False (c : rest) = c : go False rest
65
66 -- | If this comma is trailing (only whitespace/comments before ] or }),
67 -- return the remaining string starting at the closing bracket.
68 skipTrailingComma :: String -> Maybe String
69 skipTrailingComma [] = Just []
70 skipTrailingComma ('/' : '/' : rest) =
71 skipTrailingComma (dropWhile (/= '\n') rest)
72 skipTrailingComma (c : rest)
73 | c `elem` (" \t\n\r" :: String) = skipTrailingComma rest
74 | c == ']' || c == '}' = Just (c : rest)
75 | otherwise = Nothing
76
77 -- ---------------------------------------------------------------------------
78 -- OpenClaw config types
79 -- ---------------------------------------------------------------------------
80
81 data OpenClawConfig = OpenClawConfig
82 { _oc_defaultModel :: Maybe Text
83 , _oc_workspace :: Maybe Text
84 , _oc_agents :: [OpenClawAgent]
85 , _oc_signal :: Maybe OpenClawSignal
86 , _oc_telegram :: Maybe OpenClawTelegram
87 , _oc_thinkingDefault :: Maybe Text
88 , _oc_timeoutSeconds :: Maybe Int
89 , _oc_userTimezone :: Maybe Text
90 }
91 deriving stock (Show, Eq)
92
93 data OpenClawAgent = OpenClawAgent
94 { _oca_id :: Text
95 , _oca_systemPrompt :: Maybe Text
96 , _oca_model :: Maybe Text
97 , _oca_toolProfile :: Maybe Text
98 , _oca_workspace :: Maybe Text
99 }
100 deriving stock (Show, Eq)
101
102 data OpenClawSignal = OpenClawSignal
103 { _ocs_account :: Maybe Text
104 , _ocs_dmPolicy :: Maybe Text
105 , _ocs_allowFrom :: Maybe [Text]
106 }
107 deriving stock (Show, Eq)
108
109 data OpenClawTelegram = OpenClawTelegram
110 { _oct_botToken :: Maybe Text
111 , _oct_dmPolicy :: Maybe Text
112 , _oct_allowFrom :: Maybe [Text]
113 }
114 deriving stock (Show, Eq)
115
116 -- ---------------------------------------------------------------------------
117 -- OpenClaw config parsing
118 -- ---------------------------------------------------------------------------
119
120 parseOpenClawConfig :: Value -> Either String OpenClawConfig
121 parseOpenClawConfig = parseEither parseOC
122
123 parseOC :: Value -> Parser OpenClawConfig
124 parseOC = withObject "OpenClawConfig" $ \o -> do
125 mAgents <- o .:? "agents"
126 defaults <- maybe (pure emptyParsedDefaults) parseDefaults mAgents
127 agents <- maybe (pure []) parseAgentList mAgents
128 mChannels <- o .:? "channels"
129 signal <- maybe (pure Nothing) (withObject "channels" (.:? "signal") >=> traverse parseSignalCfg) mChannels
130 telegram <- maybe (pure Nothing) (withObject "channels" (.:? "telegram") >=> traverse parseTelegramCfg) mChannels
131 pure OpenClawConfig
132 { _oc_defaultModel = _pd_model defaults
133 , _oc_workspace = _pd_workspace defaults
134 , _oc_agents = agents
135 , _oc_signal = signal
136 , _oc_telegram = telegram
137 , _oc_thinkingDefault = _pd_thinkingDefault defaults
138 , _oc_timeoutSeconds = _pd_timeoutSeconds defaults
139 , _oc_userTimezone = _pd_userTimezone defaults
140 }
141
142 data ParsedDefaults = ParsedDefaults
143 { _pd_model :: Maybe Text
144 , _pd_workspace :: Maybe Text
145 , _pd_thinkingDefault :: Maybe Text
146 , _pd_timeoutSeconds :: Maybe Int
147 , _pd_userTimezone :: Maybe Text
148 }
149
150 emptyParsedDefaults :: ParsedDefaults
151 emptyParsedDefaults = ParsedDefaults Nothing Nothing Nothing Nothing Nothing
152
153 parseDefaults :: Value -> Parser ParsedDefaults
154 parseDefaults = withObject "agents" $ \o -> do
155 mDefaults <- o .:? "defaults"
156 case mDefaults of
157 Nothing -> pure emptyParsedDefaults
158 Just defVal -> flip (withObject "defaults") defVal $ \d -> do
159 mModelVal <- d .:? "model"
160 model <- case mModelVal of
161 Just (Object m) -> m .:? "primary"
162 Just (String s) -> pure (Just s)
163 _ -> pure Nothing
164 ws <- d .:? "workspace"
165 thinking <- d .:? "thinkingDefault"
166 timeout <- d .:? "timeoutSeconds"
167 tz <- d .:? "userTimezone"
168 pure ParsedDefaults
169 { _pd_model = model
170 , _pd_workspace = ws
171 , _pd_thinkingDefault = thinking
172 , _pd_timeoutSeconds = timeout
173 , _pd_userTimezone = tz
174 }
175
176 parseAgentList :: Value -> Parser [OpenClawAgent]
177 parseAgentList = withObject "agents" $ \o -> do
178 mList <- o .:? "list"
179 case mList of
180 Nothing -> pure []
181 Just agents -> mapM parseAgentDef agents
182
183 parseAgentDef :: Value -> Parser OpenClawAgent
184 parseAgentDef = withObject "OpenClawAgent" $ \o -> do
185 agentId <- o .: "id"
186 systemPrompt <- o .:? "systemPrompt"
187 mModelVal <- o .:? "model"
188 let model = case mModelVal of
189 Just (Object m) -> parseMaybe (.: "primary") m
190 Just (String s) -> Just s
191 _ -> Nothing
192 mTools <- o .:? "tools"
193 let toolProfile = mTools >>= parseMaybe (withObject "tools" (.: "profile"))
194 ws <- o .:? "workspace"
195 pure OpenClawAgent
196 { _oca_id = agentId
197 , _oca_systemPrompt = systemPrompt
198 , _oca_model = model
199 , _oca_toolProfile = toolProfile
200 , _oca_workspace = ws
201 }
202
203 parseSignalCfg :: Value -> Parser OpenClawSignal
204 parseSignalCfg = withObject "signal" $ \o ->
205 OpenClawSignal <$> o .:? "account" <*> o .:? "dmPolicy" <*> o .:? "allowFrom"
206
207 parseTelegramCfg :: Value -> Parser OpenClawTelegram
208 parseTelegramCfg = withObject "telegram" $ \o ->
209 OpenClawTelegram <$> o .:? "botToken" <*> o .:? "dmPolicy" <*> o .:? "allowFrom"
210
211 -- ---------------------------------------------------------------------------
212 -- $include resolution
213 -- ---------------------------------------------------------------------------
214
215 -- | Resolve $include directives in a JSON Value, up to a max depth.
216 resolveIncludes :: Int -> FilePath -> Value -> IO Value
217 resolveIncludes maxDepth baseDir val
218 | maxDepth <= 0 = pure val
219 | otherwise = case val of
220 Object o -> case KM.lookup (Key.fromText "$include") o of
221 Just (String path) -> do
222 let fullPath = baseDir </> T.unpack path
223 included <- loadJson5File fullPath
224 case included of
225 Left _ -> pure val
226 Right inc -> resolveIncludes (maxDepth - 1) (takeDirectory fullPath) inc
227 Just (Array paths) -> do
228 resolved <- mapM (resolveIncludePath (maxDepth - 1) baseDir) (V.toList paths)
229 pure (foldl deepMerge (Object KM.empty) resolved)
230 _ -> do
231 resolved <- KM.traverseWithKey (\_ v -> resolveIncludes maxDepth baseDir v) o
232 pure (Object resolved)
233 _ -> pure val
234
235 resolveIncludePath :: Int -> FilePath -> Value -> IO Value
236 resolveIncludePath depth baseDir (String path) = do
237 let fullPath = baseDir </> T.unpack path
238 loaded <- loadJson5File fullPath
239 case loaded of
240 Left _ -> pure (Object KM.empty)
241 Right inc -> resolveIncludes depth (takeDirectory fullPath) inc
242 resolveIncludePath _ _ other = pure other
243
244 deepMerge :: Value -> Value -> Value
245 deepMerge (Object a) (Object b) = Object (KM.unionWith deepMerge a b)
246 deepMerge _ b = b
247
248 -- | Load and parse a JSON5 file.
249 loadJson5File :: FilePath -> IO (Either String Value)
250 loadJson5File path = do
251 result <- try @IOException (TIO.readFile path)
252 case result of
253 Left err -> pure (Left (show err))
254 Right text ->
255 let cleaned = stripJson5 text
256 bs = TE.encodeUtf8 cleaned
257 in pure (eitherDecodeStrict' bs)
258
259 -- ---------------------------------------------------------------------------
260 -- Import execution
261 -- ---------------------------------------------------------------------------
262
263 data ImportResult = ImportResult
264 { _ir_configWritten :: Bool
265 , _ir_agentsWritten :: [Text]
266 , _ir_skippedFields :: [Text]
267 , _ir_warnings :: [Text]
268 }
269 deriving stock (Show, Eq)
270
271 -- | Import an OpenClaw config file into PureClaw's directory structure.
272 -- Writes to @configDir/config.toml@ and @configDir/agents/*/AGENTS.md@.
273 importOpenClawConfig :: FilePath -> FilePath -> IO (Either Text ImportResult)
274 importOpenClawConfig openclawPath configDir = do
275 loaded <- loadJson5File openclawPath
276 case loaded of
277 Left err -> pure (Left ("Failed to parse OpenClaw config: " <> T.pack err))
278 Right rawJson -> do
279 resolved <- resolveIncludes 3 (takeDirectory openclawPath) rawJson
280 case parseOpenClawConfig resolved of
281 Left err -> pure (Left ("Failed to extract config fields: " <> T.pack err))
282 Right ocConfig -> writeImportedConfig configDir ocConfig
283
284 writeImportedConfig :: FilePath -> OpenClawConfig -> IO (Either Text ImportResult)
285 writeImportedConfig configDir ocConfig = do
286 Dir.createDirectoryIfMissing True configDir
287 let agentsDir = configDir </> "agents"
288
289 -- Write main config.toml
290 let configContent = buildConfigToml ocConfig
291 TIO.writeFile (configDir </> "config.toml") configContent
292
293 -- Write agent AGENTS.md files
294 agentNames <- mapM (writeAgentFile agentsDir) (_oc_agents ocConfig)
295
296 -- Write default agent if there are defaults
297 case (_oc_defaultModel ocConfig, _oc_workspace ocConfig) of
298 (Nothing, Nothing) -> pure ()
299 _ -> do
300 let defaultDir = agentsDir </> "default"
301 Dir.createDirectoryIfMissing True defaultDir
302 TIO.writeFile (defaultDir </> "AGENTS.md") $ T.unlines
303 [ "---"
304 , maybe "" ("model: " <>) (_oc_defaultModel ocConfig)
305 , maybe "" ("workspace: " <>) (_oc_workspace ocConfig)
306 , "---"
307 , ""
308 , "Default PureClaw agent."
309 ]
310
311 pure (Right ImportResult
312 { _ir_configWritten = True
313 , _ir_agentsWritten = agentNames
314 , _ir_skippedFields = []
315 , _ir_warnings = []
316 })
317
318 buildConfigToml :: OpenClawConfig -> Text
319 buildConfigToml oc = T.unlines $ concatMap (filter (not . T.null))
320 [ maybe [] (\m -> ["model = " <> quoted m]) (_oc_defaultModel oc)
321 , maybe [] (\t -> ["reasoning_effort = " <> quoted (mapThinkingDefault t)]) (_oc_thinkingDefault oc)
322 , maybe [] (\s -> ["max_turns = " <> T.pack (show (computeMaxTurns s))]) (_oc_timeoutSeconds oc)
323 , maybe [] (\tz -> ["timezone = " <> quoted tz]) (_oc_userTimezone oc)
324 , case _oc_signal oc of
325 Nothing -> []
326 Just sig ->
327 [ ""
328 , "[signal]"
329 ] ++ catMaybes
330 [ fmap (\a -> "account = " <> quoted a) (_ocs_account sig)
331 , fmap (\p -> "dm_policy = " <> quoted (camelToSnake p)) (_ocs_dmPolicy sig)
332 , fmap (\af -> "allow_from = " <> fmtList af) (_ocs_allowFrom sig)
333 ]
334 , case _oc_telegram oc of
335 Nothing -> []
336 Just tg ->
337 [ ""
338 , "[telegram]"
339 ] ++ catMaybes
340 [ fmap (\t -> "bot_token = " <> quoted t) (_oct_botToken tg)
341 , fmap (\p -> "dm_policy = " <> quoted (camelToSnake p)) (_oct_dmPolicy tg)
342 , fmap (\af -> "allow_from = " <> fmtList af) (_oct_allowFrom tg)
343 ]
344 ]
345 where
346 catMaybes = foldr (\x acc -> maybe acc (: acc) x) []
347
348 writeAgentFile :: FilePath -> OpenClawAgent -> IO Text
349 writeAgentFile agentsDir agent = do
350 let agentDir = agentsDir </> T.unpack (_oca_id agent)
351 Dir.createDirectoryIfMissing True agentDir
352 let frontmatterLines = filter (/= "")
353 [ maybe "" ("model: " <>) (_oca_model agent)
354 , maybe "" ("tool_profile: " <>) (_oca_toolProfile agent)
355 , maybe "" ("workspace: " <>) (_oca_workspace agent)
356 ]
357 hasFrontmatter = not (null frontmatterLines)
358 header = if hasFrontmatter
359 then ["---"] ++ frontmatterLines ++ ["---", ""]
360 else []
361 body = fromMaybe "" (_oca_systemPrompt agent)
362 TIO.writeFile (agentDir </> "AGENTS.md") (T.unlines (header ++ [body]))
363 pure (_oca_id agent)
364
365 quoted :: Text -> Text
366 quoted t = "\"" <> T.replace "\"" "\\\"" t <> "\""
367
368 fmtList :: [Text] -> Text
369 fmtList xs = "[" <> T.intercalate ", " (map quoted xs) <> "]"
370
371 camelToSnake :: Text -> Text
372 camelToSnake = T.concatMap $ \c ->
373 if Char.isAsciiUpper c
374 then T.pack ['_', Char.toLower c]
375 else T.singleton c
376
377 -- | Map OpenClaw thinkingDefault to PureClaw reasoning_effort.
378 -- "always"/"high" → "high"; "auto"/"medium" → "medium"; everything else → "low"
379 mapThinkingDefault :: Text -> Text
380 mapThinkingDefault t = case T.toLower t of
381 "always" -> "high"
382 "high" -> "high"
383 "auto" -> "medium"
384 "medium" -> "medium"
385 _ -> "low" -- off, low, none, minimal
386
387 -- | Convert OpenClaw timeoutSeconds to max_turns (seconds / 10, clamped to [1, 200]).
388 computeMaxTurns :: Int -> Int
389 computeMaxTurns secs = min 200 (max 1 (secs `div` 10))
390
391 -- ---------------------------------------------------------------------------
392 -- CLI options for import command
393 -- ---------------------------------------------------------------------------
394
395 -- | Options for the import command.
396 data ImportOptions = ImportOptions
397 { _io_from :: Maybe FilePath
398 , _io_to :: Maybe FilePath
399 }
400 deriving stock (Show, Eq)
401
402 -- | Resolve import options: handle backward compat with a single positional arg.
403 -- If a positional arg is given:
404 -- - If it's a directory, use as --from
405 -- - If it's a .json file, use dirname as --from
406 -- Defaults: --from = ~/.openclaw, --to = ~/.pureclaw
407 resolveImportOptions :: ImportOptions -> Maybe FilePath -> IO (FilePath, FilePath)
408 resolveImportOptions opts mPositional = do
409 home <- Dir.getHomeDirectory
410 let defaultFrom = home </> ".openclaw"
411 defaultTo = home </> ".pureclaw"
412 fromDir <- case mPositional of
413 Just pos -> do
414 isDir <- Dir.doesDirectoryExist pos
415 if isDir
416 then pure pos
417 else if takeExtension pos == ".json"
418 then pure (takeDirectory pos)
419 else pure pos -- let it fail later with a clear error
420 Nothing -> pure (fromMaybe defaultFrom (_io_from opts))
421 let toDir = fromMaybe defaultTo (_io_to opts)
422 pure (fromDir, toDir)
423
424 -- ---------------------------------------------------------------------------
425 -- Full directory import
426 -- ---------------------------------------------------------------------------
427
428 -- | Result of a full OpenClaw directory import.
429 data DirImportResult = DirImportResult
430 { _dir_configResult :: ImportResult
431 , _dir_credentialsOk :: Bool
432 , _dir_deviceId :: Maybe Text
433 , _dir_workspacePath :: Maybe FilePath
434 , _dir_extraWorkspaces :: [FilePath]
435 , _dir_cronSkipped :: Bool
436 , _dir_modelsImported :: Bool
437 , _dir_warnings :: [Text]
438 }
439 deriving stock (Show, Eq)
440
441 -- | Import a full OpenClaw state directory into PureClaw.
442 importOpenClawDir :: FilePath -> FilePath -> IO (Either Text DirImportResult)
443 importOpenClawDir fromDir toDir = do
444 -- 1. Import openclaw.json → config.toml (existing logic)
445 let configPath = fromDir </> "openclaw.json"
446 configExists <- Dir.doesFileExist configPath
447 if not configExists
448 then pure (Left $ "No openclaw.json found in " <> T.pack fromDir)
449 else do
450 let configDir = toDir </> "config"
451 configResult <- importOpenClawConfig configPath configDir
452 case configResult of
453 Left err -> pure (Left err)
454 Right ir -> do
455 (addWarning, getWarnings) <- newWarnings
456
457 -- 2. Import auth-profiles.json → credentials.json
458 credOk <- importAuthProfiles fromDir toDir addWarning
459
460 -- 3. Import device.json → extract deviceId
461 mDeviceId <- importDeviceIdentity fromDir addWarning
462
463 -- 4. Copy workspace files → toDir/workspace/
464 let srcWorkspace = fromDir </> "workspace"
465 wsExists <- Dir.doesDirectoryExist srcWorkspace
466 mWorkspace <- if wsExists
467 then do
468 let destWorkspace = toDir </> "workspace"
469 copyWorkspaceFiles srcWorkspace destWorkspace addWarning
470 pure (Just destWorkspace)
471 else pure Nothing
472
473 -- 5. Find extra workspace-* directories
474 extraWs <- findExtraWorkspaces fromDir
475
476 -- 6. Check for cron jobs
477 cronExists <- Dir.doesFileExist (fromDir </> "cron" </> "jobs.json")
478
479 -- 7. Import models.json
480 modelsOk <- importModels fromDir toDir addWarning
481
482 -- 8. Append workspace/identity sections to config.toml
483 appendConfigSections configDir mWorkspace mDeviceId extraWs
484
485 ws <- getWarnings
486
487 pure (Right DirImportResult
488 { _dir_configResult = ir
489 , _dir_credentialsOk = credOk
490 , _dir_deviceId = mDeviceId
491 , _dir_workspacePath = mWorkspace
492 , _dir_extraWorkspaces = extraWs
493 , _dir_cronSkipped = cronExists
494 , _dir_modelsImported = modelsOk
495 , _dir_warnings = ws
496 })
497
498 newWarnings :: IO (Text -> IO (), IO [Text])
499 newWarnings = do
500 ref <- newIORef []
501 let addW w = modifyIORef' ref (w :)
502 getW = reverse <$> readIORef ref
503 pure (addW, getW)
504
505 -- | Import auth-profiles.json → credentials.json
506 importAuthProfiles :: FilePath -> FilePath -> (Text -> IO ()) -> IO Bool
507 importAuthProfiles fromDir toDir addWarning = do
508 let authPath = fromDir </> "agents" </> "main" </> "agent" </> "auth-profiles.json"
509 loaded <- loadJson5File authPath
510 case loaded of
511 Left _ -> do
512 addWarning "auth-profiles.json not found — no credentials imported"
513 pure False
514 Right val -> do
515 let mProfiles = parseMaybe (withObject "auth" (.: "profiles")) val
516 case mProfiles of
517 Nothing -> do
518 addWarning "auth-profiles.json has no profiles field"
519 pure False
520 Just (Object profiles) -> do
521 let creds = KM.foldrWithKey extractCred [] profiles
522 if null creds
523 then do
524 addWarning "No API tokens found in auth-profiles.json"
525 pure False
526 else do
527 Dir.createDirectoryIfMissing True toDir
528 let credsJson = object (map (uncurry (.=)) creds)
529 LBS.writeFile (toDir </> "credentials.json") (encode credsJson)
530 pure True
531 _ -> do
532 addWarning "auth-profiles.json profiles field is not an object"
533 pure False
534 where
535 extractCred _key val acc =
536 case parseMaybe parseProfile val of
537 Just (provider, token) -> (Key.fromText provider, String token) : acc
538 Nothing -> acc
539
540 parseProfile = withObject "profile" $ \o -> do
541 provider <- o .: "provider"
542 token <- o .: "token"
543 pure (provider :: Text, token :: Text)
544
545 -- | Import device.json → extract deviceId
546 importDeviceIdentity :: FilePath -> (Text -> IO ()) -> IO (Maybe Text)
547 importDeviceIdentity fromDir addWarning = do
548 let devicePath = fromDir </> "identity" </> "device.json"
549 loaded <- loadJson5File devicePath
550 case loaded of
551 Left _ -> do
552 addWarning "identity/device.json not found — no device ID imported"
553 pure Nothing
554 Right val ->
555 case parseMaybe (withObject "device" (.: "deviceId")) val of
556 Just did -> pure (Just did)
557 Nothing -> do
558 addWarning "identity/device.json has no deviceId field"
559 pure Nothing
560
561 -- | Find extra workspace-* directories
562 findExtraWorkspaces :: FilePath -> IO [FilePath]
563 findExtraWorkspaces fromDir = do
564 entries <- try @IOException (Dir.listDirectory fromDir)
565 case entries of
566 Left _ -> pure []
567 Right es -> do
568 let candidates = filter ("workspace-" `T.isPrefixOf`) (map T.pack es)
569 dirs <- filterM (\e -> Dir.doesDirectoryExist (fromDir </> T.unpack e)) candidates
570 pure (map (\d -> fromDir </> T.unpack d) dirs)
571 where
572 filterM _ [] = pure []
573 filterM p (x:xs) = do
574 b <- p x
575 rest <- filterM p xs
576 if b then pure (x : rest) else pure rest
577
578 -- | Import models.json → models.json in toDir
579 importModels :: FilePath -> FilePath -> (Text -> IO ()) -> IO Bool
580 importModels fromDir toDir addWarning = do
581 let modelsPath = fromDir </> "agents" </> "main" </> "agent" </> "models.json"
582 loaded <- loadJson5File modelsPath
583 case loaded of
584 Left _ -> do
585 addWarning "agents/main/agent/models.json not found — no model overrides imported"
586 pure False
587 Right val -> do
588 Dir.createDirectoryIfMissing True toDir
589 LBS.writeFile (toDir </> "models.json") (encode val)
590 pure True
591
592 -- | The workspace files that are copied during import.
593 -- These are the key files that define agent identity, context, and memory.
594 workspaceFiles :: [FilePath]
595 workspaceFiles = ["SOUL.md", "AGENTS.md", "MEMORY.md", "USER.md"]
596
597 -- | Copy workspace files from the OpenClaw workspace to the PureClaw workspace.
598 -- Only copies files that exist; missing files are silently skipped.
599 -- IO failures on individual files are reported as warnings (not fatal).
600 copyWorkspaceFiles :: FilePath -> FilePath -> (Text -> IO ()) -> IO ()
601 copyWorkspaceFiles srcDir destDir addWarning = do
602 Dir.createDirectoryIfMissing True destDir
603 mapM_ copyIfExists workspaceFiles
604 where
605 copyIfExists name = do
606 let src = srcDir </> name
607 dest = destDir </> name
608 exists <- Dir.doesFileExist src
609 when exists $ do
610 result <- try @IOException (Dir.copyFile src dest)
611 case result of
612 Right () -> pure ()
613 Left err -> addWarning ("Failed to copy " <> T.pack name <> ": " <> T.pack (show err))
614
615 -- | Append workspace and identity sections to config.toml
616 appendConfigSections :: FilePath -> Maybe FilePath -> Maybe Text -> [FilePath] -> IO ()
617 appendConfigSections configDir mWorkspace mDeviceId extraWs = do
618 let configPath = configDir </> "config.toml"
619 exists <- Dir.doesFileExist configPath
620 if not exists
621 then pure ()
622 else do
623 existing <- TIO.readFile configPath
624 let sections = T.unlines $ concat
625 [ case mWorkspace of
626 Nothing -> []
627 Just ws ->
628 [ ""
629 , "[workspace]"
630 , "path = " <> quoted (T.pack ws)
631 ]
632 , case mDeviceId of
633 Nothing -> []
634 Just did ->
635 [ ""
636 , "[identity]"
637 , "device_id = " <> quoted did
638 ]
639 , if null extraWs then []
640 else
641 [ ""
642 , "# Additional OpenClaw workspaces found:"
643 ] ++ map (\ws -> "# workspace: " <> T.pack ws) extraWs
644 ]
645 TIO.writeFile configPath (existing <> sections)