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)